From 8d8eefc90f8f493a6dc8a00671ea9099ace39437 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Fri, 3 Jul 2026 16:17:21 -0700 Subject: [PATCH 01/19] feat(api): add express wrappers These wrappers add zod-based input validation, type-safety, and reflection capabilities. To be used in the future for generating openapi definitions. --- src/app.ts | 5 +- src/routes/api.ts | 84 +++++++++--------- src/routes/helper.ts | 199 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 43 deletions(-) create mode 100644 src/routes/helper.ts diff --git a/src/app.ts b/src/app.ts index 7690451..bd158a4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,11 +1,12 @@ import express from "express"; import mbus from "./routes/api" +import { addRouter, dumpReflectionInfo, reflection } from "./routes/helper"; const app = express(); app.use(express.json()); -app.use("/mbus/api/v3", mbus); +addRouter(app, "/mbus/api/v3", mbus); app.use("/docs", express.static("docs")); const PORT = process.env.PORT || 3000; @@ -13,4 +14,6 @@ const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); + if (reflection) + dumpReflectionInfo(); }); \ No newline at end of file diff --git a/src/routes/api.ts b/src/routes/api.ts index cc03750..43085e4 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -9,6 +9,7 @@ import * as journeyService from '../services/journey'; import * as reminderService from '../services/reminder'; import * as graphBuilder from '../services/graphBuilder'; import { startBackgroundJobs } from '../jobs'; +import { addGetRoute, HandlerReturn, makeFailureResponse, makeSuccessResponse } from "./helper"; /** * Express router for the MBus API v3. @@ -431,7 +432,7 @@ export function getStartupInfo(req: express.Request, res: express.Response) { res.json({ min_supported_version: "2.0.0", why_update_message: { title: "Update Needed", subtitle: "You need to update to the latest version for the app to work properly." }, - persistant_message: { title: "", subtitle: ""}, + persistant_message: { title: "", subtitle: "" }, one_time_message: { title: "", subtitle: "" }, bus_image_version: "1", }); @@ -529,7 +530,7 @@ export function unsetReminder(req: express.Request, res: express.Response) { res.status(400); res.send(result.error.message); } else { - const { token ,stpid, rtid } = result.data; + const { token, stpid, rtid } = result.data; const info = reminderService.infoToUseForRoute(rtid); if (info === null) { res.status(400); @@ -567,47 +568,46 @@ export function swapToken(req: express.Request, res: express.Response) { } router.post('/swapToken', swapToken); -export interface ActiveReminderInfo { - stpid: string - rtid: string - thresh: number | null - eta: number | null -}; +const Token = z.string().transform(reminderService.registrationToken).meta({ id: "Token" }) +const ActiveReminder = z.object({ + stpid: z.string(), + rtid: z.string(), + thresh: z.number().nullable(), + eta: z.number().nullable(), +}).meta({ id: "Reminder" }); -/** - * @param req - Express request, token is path encoded - * @param res - Express response - */ -export function activeRemindersForToken( - req: express.Request, - res: express.Response<{ reminders: Array }> -) { - const subscriptionInfo = (r: reminderService.PreThreshold | reminderService.PostThreshold): - ActiveReminderInfo => +addGetRoute( + router, '/activeReminders/:token', { - return { - stpid: r.event.stpid, - rtid: r.event.rtid, - thresh: r.stage === 0 ? r.thresh : null, - eta: r.stage === 0 ? r.candidateVidPredPrev : r.vidPredPrev + params: z.object({ token: Token }), + query: z.unknown(), + resBody: z.object({ reminders: z.array(ActiveReminder) }), + }, + ({token}, _) => { + const subscriptionInfo = (r: reminderService.PreThreshold | reminderService.PostThreshold) => { + return { + stpid: r.event.stpid, + rtid: r.event.rtid, + thresh: r.stage === 0 ? r.thresh : null, + eta: r.stage === 0 ? r.candidateVidPredPrev : r.vidPredPrev + }; }; - }; - const token = reminderService.registrationToken(req.params.registrationToken); - console.log(`Got request for active reminders of ${token}`); - res.status(200); - const universityReminders = reminderService - .universityReminderSubscriptions - .activeRemindersFor(token) - .map(subscriptionInfo); - const rideReminders = reminderService - .rideReminderSubscriptions - .activeRemindersFor(token) - .map(subscriptionInfo); - res.send({ - reminders: universityReminders.concat(rideReminders) - }); -} -router.get('/activeReminders/:registrationToken', activeRemindersForToken); + console.log(`Got request for active reminders of ${token}`); + const universityReminders = reminderService + .universityReminderSubscriptions + .activeRemindersFor(token) + .map(subscriptionInfo); + const rideReminders = reminderService + .rideReminderSubscriptions + .activeRemindersFor(token) + .map(subscriptionInfo); + return makeSuccessResponse(200, { reminders: universityReminders.concat(rideReminders) }); + }, + { + summary: "active reminders", + description: `big long description idk, gets the reminders associated with a **registration token**, which is gotten from fcm or smth` + }, +) const ModifyRemindersBody = z.object({ token: z.string(), @@ -645,7 +645,7 @@ export function modifyReminders(req: express.Request, res: express.Response) { reminderService.registrationToken(token), predsByStopId, Date.now() - ); + ); } else { reminderSubscriptions.remove( event, reminderService.registrationToken(token) @@ -670,7 +670,7 @@ export function notifyMeLater(req: express.Request, res: express.Response) { } setTimeout(() => { console.log(`sending test push notification to ${registrationToken}`); - reminderService.sendNotifToAll({ title: "hi", body: "hello world!"}, new Set([registrationToken])); + reminderService.sendNotifToAll({ title: "hi", body: "hello world!" }, new Set([registrationToken])); }, 0); res.sendStatus(200); } diff --git a/src/routes/helper.ts b/src/routes/helper.ts new file mode 100644 index 0000000..5a13335 --- /dev/null +++ b/src/routes/helper.ts @@ -0,0 +1,199 @@ +/** + * Wrappers around stuff you would otherwise do with express but with reflection + * capabilities used for openapi specification generation. + * + * The `req` and `res` objects aren't provided to the passed in handler + * functions, if you're doing something more complicated just use the router + * directly for now. + * + * Nested routing not supported yet, but should probably be added since api.ts + * is getting long. + * + * Extra functionality will be added as needed. + * + * TODO: add examples + * TODO: add tests + * TODO: use doc info, generate docs + * TODO: convert path acceptors from express format to openapi format + */ + +import express from 'express'; +import z from 'zod'; +import { JSONSchema, ToJSONSchemaParams } from 'zod/v4/core'; + +/** is reflection enabled? */ +export const reflection = true; +const info: ReflectionInfoRaw = { + routers: [], + routes: [] +}; + +/** + * unresolved: how to get descriptions from the ts-doc comments? + * probably handled by a typedoc plugin, or passed directly + */ +interface ReflectionInfoRaw { + routers: Array<{ route: string, router: express.Router }>, + /** full routes along with req+res schemas, routes are incomplete until info is finalized */ + routes: Array<{ + router: express.Router, + pathSuffix: string, + method: 'get', + params: z.ZodType, + query: z.ZodType, + resBody: z.ZodType, + }>, +}; + +interface ReflectionInfo { + routes: Array<{ + path: string, method: 'get', + params: JSONSchema.BaseSchema, query: JSONSchema.BaseSchema, resBody: JSONSchema.BaseSchema, + }>, + model: JSONSchema.BaseSchema, +}; + +function finalize(info: ReflectionInfoRaw): ReflectionInfo { + // TODO: try output first then fallback to input + const schemaOpts: ToJSONSchemaParams = { + reused: 'ref', + io: 'input', + } + const resultRoutes = []; + const model: Record = {}; + for (const route of info.routes) { + const basePath = info.routers.find((r) => r.router == route.router)?.route; + if (basePath == undefined) { + throw new Error('route has missing base path'); + } + const path = basePath + route.pathSuffix; + resultRoutes.push({ + path, method: route.method, + params: route.params.toJSONSchema(schemaOpts), + query: route.query.toJSONSchema(schemaOpts), + resBody: route.resBody.toJSONSchema(schemaOpts), + }); + model[path + ' params'] = route.params; + model[path + ' query'] = route.query; + model[path + ' resBody'] = route.resBody; + } + return { + routes: resultRoutes, + model: z.object(model).toJSONSchema(schemaOpts), + }; +} + +export function dumpReflectionInfo() { + const finalized = finalize(info); + console.log(JSON.stringify(finalized, null, 4)); +} + +export function addRouter(app: express.Express, route: string, router: express.Router) { + if (reflection) { + info.routers.push({ route, router }); + } + app.use(route, router); +} + +export interface GetFormat< + P extends z.ZodType, + Q extends z.ZodType, + RB extends z.ZodType +> { + /** path parameters */ + params: P, + query: Q, + resBody: RB, +} + +/** + * feel free to add more codes here and to the make*[a-z]Response functions as you need them + */ +export type HandlerReturn = { + success: true, status: 200 | 201 | 202 | 203 | 205, json: T +} | { + success: false, status: 400 | 401 | 403 | 404 | 500, error: string +}; + +/** + * helper functions that should avoid weird typechecker issues + */ +export function makeSuccessResponse(status: 200 | 201 | 202 | 203 | 205, json: T): HandlerReturn { + return { success: true, status, json }; +} + +/** + * helper functions that should avoid weird typechecker issues + */ +export function makeFailureResponse(status: 400 | 401 | 403 | 404 | 500, error: string): HandlerReturn { + return { success: false, status, error }; +} + +/** + * wrapper around router.get with built in validation and schema recording + * + * the `req` and `res` objects aren't provided to the passed in handler, if + * you're doing something more complicated just use the router direclty for + * now, the functionality needed will be incorporated + */ +export function addGetRoute< + P extends z.ZodType, + Q extends z.ZodType, + RB extends z.ZodType +>( + router: express.Router, + path: string, + format: GetFormat, + handler: (params: z.infer

, query: z.infer) => HandlerReturn>, + docs?: { + /** a short description of what is route does */ + summary?: string, + /** a longer explanation, commonmark accepted */ + description?: string, + }, +) { + const { params: paramsSchema, query: querySchema, resBody: resBodySchema } = format; + + if (reflection) { + info.routes.push({ + router, method: 'get', pathSuffix: path, + params: paramsSchema, query: querySchema, resBody: resBodySchema, + }) + } + + router.get(path, (req: express.Request, res: express.Response | { error: string }>) => { + const { status, json } = determineResponse(req); + res.status(status).json(json); + }); + + const determineResponse = (req: express.Request): { status: number, json: z.infer | { error: string } } => { + let params = paramsSchema.safeParse(req.params); + if (params.error) { + return { status: 400, json: { error: "invalid path params: " + params.error.message } }; + } + let query = querySchema.safeParse(req.query); + if (query.error) { + return { status: 400, json: { error: "invalid query params: " + query.error.message } }; + } + try { + const result = handler(params.data, query.data); + if (result.success) { + return { status: result.status, json: result.json }; + } else { + return { status: result.status, json: { error: result.error } }; + } + } catch (e) { + console.error(`uncaught exception in wrapped route: ${e}`) + if (e instanceof Error) { + return { status: 500, json: { error: e.message } } + } else { + return { status: 500, json: { error: JSON.stringify(e) } } + } + } + } +} + +/** + * wrapper around router.post with built in validation and schema recording + * TODO: make this + */ From 671f0632fd328ba23dbafa0b4cfa3d6c7c99db98 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Fri, 3 Jul 2026 23:00:32 -0700 Subject: [PATCH 02/19] feat(api): openapi docs generation --- src/routes/api.ts | 6 +- src/routes/helper.ts | 164 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 134 insertions(+), 36 deletions(-) diff --git a/src/routes/api.ts b/src/routes/api.ts index 43085e4..bdccfe4 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -9,7 +9,7 @@ import * as journeyService from '../services/journey'; import * as reminderService from '../services/reminder'; import * as graphBuilder from '../services/graphBuilder'; import { startBackgroundJobs } from '../jobs'; -import { addGetRoute, HandlerReturn, makeFailureResponse, makeSuccessResponse } from "./helper"; +import { addGetRoute, makeSuccessResponse } from "./helper"; /** * Express router for the MBus API v3. @@ -580,10 +580,10 @@ addGetRoute( router, '/activeReminders/:token', { params: z.object({ token: Token }), - query: z.unknown(), + query: z.object(), resBody: z.object({ reminders: z.array(ActiveReminder) }), }, - ({token}, _) => { + ({ token }, _) => { const subscriptionInfo = (r: reminderService.PreThreshold | reminderService.PostThreshold) => { return { stpid: r.event.stpid, diff --git a/src/routes/helper.ts b/src/routes/helper.ts index 5a13335..cfa3ed7 100644 --- a/src/routes/helper.ts +++ b/src/routes/helper.ts @@ -11,10 +11,9 @@ * * Extra functionality will be added as needed. * + * TODO: post support * TODO: add examples * TODO: add tests - * TODO: use doc info, generate docs - * TODO: convert path acceptors from express format to openapi format */ import express from 'express'; @@ -39,53 +38,162 @@ interface ReflectionInfoRaw { router: express.Router, pathSuffix: string, method: 'get', - params: z.ZodType, - query: z.ZodType, + params: Record, + query: Record, resBody: z.ZodType, + summary: string, + description: string, }>, }; interface ReflectionInfo { routes: Array<{ - path: string, method: 'get', - params: JSONSchema.BaseSchema, query: JSONSchema.BaseSchema, resBody: JSONSchema.BaseSchema, + path: string, + method: 'get', + params: Record, + query: Record, + resBody: JSONSchema.BaseSchema, + summary: string, + description: string, }>, - model: JSONSchema.BaseSchema, + defs: Record, + // model: JSONSchema.BaseSchema, }; +interface OpenAPIGetPath { + summary: string, + description: string, + parameters: Array<{ + name: string, + in: "path" | "query", + schema: JSONSchema.JSONSchema, + required: boolean, + }> + responses: { + "2XX": { + description: "success", + content: { + "application/json": { + schema: JSONSchema.JSONSchema, + } + } + } + } +} + +/** the subset of the openapi format(s) we are concerned with generating */ +interface OpenAPI { + openapi: "3.1.2", + info: { + title: string, + version: string, + }, + components: { + schemas: Record, + }, + paths: Record>, +} + function finalize(info: ReflectionInfoRaw): ReflectionInfo { + // replace $def with components/schemas + const fixSchema = (s: T): T => { + if (typeof s !== 'object' || !s) return s; + if ('$ref' in s && typeof s.$ref == 'string') + s.$ref = s.$ref.replace('$defs', 'components/schemas'); + for (const v of Object.values(s)) { + fixSchema(v); + } + return s; + }; + // TODO: try output first then fallback to input const schemaOpts: ToJSONSchemaParams = { - reused: 'ref', + // reused: 'ref', io: 'input', } const resultRoutes = []; + + // used to get the shared $defs const model: Record = {}; + for (const route of info.routes) { const basePath = info.routers.find((r) => r.router == route.router)?.route; if (basePath == undefined) { throw new Error('route has missing base path'); } - const path = basePath + route.pathSuffix; + const path = (basePath + route.pathSuffix).replace(/:([A-Za-z0-9_]+)/, "{$1}"); + const finalParams: Record = {}; + for (const param in route.params) { + const zodSchema = route.params[param]; + model[path + ' params ' + param] = zodSchema; + finalParams[param] = fixSchema(zodSchema.toJSONSchema(schemaOpts)); + } + const finalQuery: Record = {}; + for (const key in route.query) { + const zodSchema = route.query[key]; + model[path + '?' + key] = zodSchema; + finalQuery[key] = fixSchema(zodSchema.toJSONSchema(schemaOpts)); + } resultRoutes.push({ path, method: route.method, - params: route.params.toJSONSchema(schemaOpts), - query: route.query.toJSONSchema(schemaOpts), - resBody: route.resBody.toJSONSchema(schemaOpts), + params: finalParams, + query: finalQuery, + resBody: fixSchema(route.resBody.toJSONSchema(schemaOpts)), + summary: route.summary, + description: route.description, }); - model[path + ' params'] = route.params; - model[path + ' query'] = route.query; model[path + ' resBody'] = route.resBody; } return { routes: resultRoutes, - model: z.object(model).toJSONSchema(schemaOpts), + defs: fixSchema(z.object(model).toJSONSchema(schemaOpts)).$defs ?? {}, }; } +function makeOpenAPI(info: ReflectionInfo): OpenAPI { + const pathsArray = info.routes.map((route) => { + const parameters: OpenAPIGetPath['parameters'] = []; + for (const name in route.params) { + parameters.push({ name: name, in: 'path', required: true, schema: route.params[name] }); + } + for (const name in route.query) { + parameters.push({ name: name, in: 'query', required: true, schema: route.query[name] }); + } + const responses: OpenAPIGetPath['responses'] = { + '2XX': { + description: 'success', + content: { + 'application/json': { schema: route.resBody } + } + } + }; + const path: OpenAPIGetPath = { + summary: route.summary, + description: route.description, + parameters, + responses, + }; + return { url: route.path, path: { get: path } }; + }); + const paths: OpenAPI['paths'] = {}; + for (const { url, path } of pathsArray) { + paths[url] = path; + } + return { + openapi: "3.1.2", + info: { + title: "Maize Bus Backend", + version: "", + }, + components: { schemas: info.defs }, + paths, + } +} + export function dumpReflectionInfo() { const finalized = finalize(info); - console.log(JSON.stringify(finalized, null, 4)); + const openAPI = makeOpenAPI(finalized); + console.log(JSON.stringify(openAPI, null, 4)); } export function addRouter(app: express.Express, route: string, router: express.Router) { @@ -95,17 +203,6 @@ export function addRouter(app: express.Express, route: string, router: express.R app.use(route, router); } -export interface GetFormat< - P extends z.ZodType, - Q extends z.ZodType, - RB extends z.ZodType -> { - /** path parameters */ - params: P, - query: Q, - resBody: RB, -} - /** * feel free to add more codes here and to the make*[a-z]Response functions as you need them */ @@ -116,14 +213,14 @@ export type HandlerReturn = { }; /** - * helper functions that should avoid weird typechecker issues + * helper function that should avoid weird typechecker issues */ export function makeSuccessResponse(status: 200 | 201 | 202 | 203 | 205, json: T): HandlerReturn { return { success: true, status, json }; } /** - * helper functions that should avoid weird typechecker issues + * helper function that should avoid weird typechecker issues */ export function makeFailureResponse(status: 400 | 401 | 403 | 404 | 500, error: string): HandlerReturn { return { success: false, status, error }; @@ -137,13 +234,13 @@ export function makeFailureResponse(status: 400 | 401 | 403 | 404 | 500, erro * now, the functionality needed will be incorporated */ export function addGetRoute< - P extends z.ZodType, - Q extends z.ZodType, + P extends z.ZodObject>, + Q extends z.ZodObject>, RB extends z.ZodType >( router: express.Router, path: string, - format: GetFormat, + format: { params: P, query: Q, resBody: RB }, handler: (params: z.infer

, query: z.infer) => HandlerReturn>, docs?: { /** a short description of what is route does */ @@ -157,7 +254,8 @@ export function addGetRoute< if (reflection) { info.routes.push({ router, method: 'get', pathSuffix: path, - params: paramsSchema, query: querySchema, resBody: resBodySchema, + params: paramsSchema.shape, query: querySchema.shape, resBody: resBodySchema, + summary: docs?.summary ?? "", description: docs?.description ?? "", }) } From 5eb79619ae8793bd3fff87c0cd3bcbb0ce652df6 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sat, 11 Jul 2026 21:08:37 -0700 Subject: [PATCH 03/19] feat(api): add support for defining post routes --- src/app.ts | 2 +- src/routes/api.ts | 26 +-- src/routes/{helper.ts => documented.ts} | 236 ++++++++++++++++++------ tsconfig.json | 4 +- 4 files changed, 191 insertions(+), 77 deletions(-) rename src/routes/{helper.ts => documented.ts} (54%) diff --git a/src/app.ts b/src/app.ts index bd158a4..f743a3d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,7 @@ import express from "express"; import mbus from "./routes/api" -import { addRouter, dumpReflectionInfo, reflection } from "./routes/helper"; +import { addRouter, dumpReflectionInfo, reflection } from "./routes/documented"; const app = express(); diff --git a/src/routes/api.ts b/src/routes/api.ts index bdccfe4..0131ee4 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -9,7 +9,7 @@ import * as journeyService from '../services/journey'; import * as reminderService from '../services/reminder'; import * as graphBuilder from '../services/graphBuilder'; import { startBackgroundJobs } from '../jobs'; -import { addGetRoute, makeSuccessResponse } from "./helper"; +import { addGetRoute, addPostRoute, emptyFormat, makeFailureResponse, makeSuccessResponse } from "./documented"; /** * Express router for the MBus API v3. @@ -488,22 +488,12 @@ router.get('/get-key-stops', getKeyStops); // Notifications / Reminders const SetReminderBody = z.object({ token: z.string(), stpid: z.string(), rtid: z.string(), thresh: z.number() }); -/** - * @param req - Express request, expects `SetReminderBody` in the body - * @param res - Express response, error message as string if error occurs - */ -export function setReminder(req: express.Request, res: express.Response) { - const result = SetReminderBody.safeParse(req.body); - if (!result.success) { - res.status(400); - res.send(result.error.message); - } else { - const { token, stpid, rtid, thresh } = result.data; +addPostRoute( + router, '/setReminder', { ...emptyFormat, reqBody: SetReminderBody }, + (_, __, { token, stpid, rtid, thresh }) => { const info = reminderService.infoToUseForRoute(rtid); if (info === null) { - res.status(400); - res.send(`Invalid route ${rtid}`); - return; + return makeFailureResponse(400, `Invalid route ${rtid}`); } const { reminderSubscriptions, predsByStopId } = info; reminderSubscriptions.add( @@ -513,11 +503,9 @@ export function setReminder(req: express.Request, res: express.Response) { predsByStopId, Date.now(), ); - res.sendStatus(200); + return makeSuccessResponse(200, {}); } - -} -router.post('/setReminder', setReminder); +); const UnsetReminderBody = z.object({ token: z.string(), stpid: z.string(), rtid: z.string() }); /** diff --git a/src/routes/helper.ts b/src/routes/documented.ts similarity index 54% rename from src/routes/helper.ts rename to src/routes/documented.ts index cfa3ed7..a3f2012 100644 --- a/src/routes/helper.ts +++ b/src/routes/documented.ts @@ -7,13 +7,17 @@ * directly for now. * * Nested routing not supported yet, but should probably be added since api.ts - * is getting long. + * is getting long (or we could separate the functions from the route defintions?). * - * Extra functionality will be added as needed. + * Extra functionality can be added as needed. * - * TODO: post support + * EXAMPLES: + * + * TODO: post support [done?] * TODO: add examples - * TODO: add tests + * TODO: support empty request and response bodies + * TODO: make actually testable? + * TODO: add tests? */ import express from 'express'; @@ -28,8 +32,7 @@ const info: ReflectionInfoRaw = { }; /** - * unresolved: how to get descriptions from the ts-doc comments? - * probably handled by a typedoc plugin, or passed directly + * doc descriptions passed as data in summary and description fields */ interface ReflectionInfoRaw { routers: Array<{ route: string, router: express.Router }>, @@ -37,30 +40,27 @@ interface ReflectionInfoRaw { routes: Array<{ router: express.Router, pathSuffix: string, - method: 'get', + summary: string, + description: string, params: Record, query: Record, resBody: z.ZodType, - summary: string, - description: string, - }>, + } & ({ method: 'get' } | { method: 'post', reqBody: z.ZodType })>, }; interface ReflectionInfo { routes: Array<{ path: string, - method: 'get', + summary: string, + description: string, params: Record, query: Record, resBody: JSONSchema.BaseSchema, - summary: string, - description: string, - }>, + } & ({ method: 'get' } | { method: 'post', reqBody: JSONSchema.BaseSchema | null })>, defs: Record, - // model: JSONSchema.BaseSchema, }; -interface OpenAPIGetPath { +interface OpenAPIPathCommon { summary: string, description: string, parameters: Array<{ @@ -68,7 +68,7 @@ interface OpenAPIGetPath { in: "path" | "query", schema: JSONSchema.JSONSchema, required: boolean, - }> + }>, responses: { "2XX": { description: "success", @@ -78,8 +78,21 @@ interface OpenAPIGetPath { } } } - } -} + }, +}; + +interface OpenAPIGetPath extends OpenAPIPathCommon { }; + +interface OpenAPIPostPath extends OpenAPIPathCommon { + requestBody: { + content: { + "application/json": { + schema: JSONSchema.JSONSchema, + } + }, + required: boolean, + }, +}; /** the subset of the openapi format(s) we are concerned with generating */ interface OpenAPI { @@ -91,7 +104,7 @@ interface OpenAPI { components: { schemas: Record, }, - paths: Record>, + paths: Record*/>, } function finalize(info: ReflectionInfoRaw): ReflectionInfo { @@ -111,7 +124,7 @@ function finalize(info: ReflectionInfoRaw): ReflectionInfo { // reused: 'ref', io: 'input', } - const resultRoutes = []; + const resultRoutes: ReflectionInfo['routes'] = []; // used to get the shared $defs const model: Record = {}; @@ -134,14 +147,30 @@ function finalize(info: ReflectionInfoRaw): ReflectionInfo { model[path + '?' + key] = zodSchema; finalQuery[key] = fixSchema(zodSchema.toJSONSchema(schemaOpts)); } - resultRoutes.push({ - path, method: route.method, + const common = { + path, params: finalParams, query: finalQuery, resBody: fixSchema(route.resBody.toJSONSchema(schemaOpts)), summary: route.summary, description: route.description, - }); + }; + switch (route.method) { + case 'get': + resultRoutes.push({ ...common, method: 'get' }); + break; + case 'post': + resultRoutes.push({ + ...common, + method: 'post', + reqBody: fixSchema(route.reqBody.toJSONSchema(schemaOpts)), + }); + model[path + ' reqBody'] = route.resBody; + break; + default: + // TODO: use eslint exhaustiveness checking + const _: never = route; + } model[path + ' resBody'] = route.resBody; } return { @@ -151,33 +180,49 @@ function finalize(info: ReflectionInfoRaw): ReflectionInfo { } function makeOpenAPI(info: ReflectionInfo): OpenAPI { - const pathsArray = info.routes.map((route) => { - const parameters: OpenAPIGetPath['parameters'] = []; - for (const name in route.params) { - parameters.push({ name: name, in: 'path', required: true, schema: route.params[name] }); - } - for (const name in route.query) { - parameters.push({ name: name, in: 'query', required: true, schema: route.query[name] }); - } - const responses: OpenAPIGetPath['responses'] = { - '2XX': { - description: 'success', - content: { - 'application/json': { schema: route.resBody } + const pathsArray = info.routes + .map((route): { url: string } & ( + { method: 'get', path: OpenAPIGetPath } | { method: 'post', path: OpenAPIPostPath } + ) => { + const parameters: OpenAPIGetPath['parameters'] = []; + for (const name in route.params) { + parameters.push({ name: name, in: 'path', required: true, schema: route.params[name] }); + } + for (const name in route.query) { + parameters.push({ name: name, in: 'query', required: true, schema: route.query[name] }); + } + const responses: OpenAPIGetPath['responses'] = { + '2XX': { + description: 'success', + content: { + 'application/json': { schema: route.resBody } + } } + }; + const common: OpenAPIPathCommon = { + summary: route.summary, + description: route.description, + parameters, + responses, + }; + switch (route.method) { + case 'get': + return { url: route.path, method: 'get', path: common }; + case 'post': + return { + url: route.path, method: 'post', path: { + requestBody: { content: { "application/json": { schema: route.reqBody } } }, ...common + } + }; } - }; - const path: OpenAPIGetPath = { - summary: route.summary, - description: route.description, - parameters, - responses, - }; - return { url: route.path, path: { get: path } }; - }); + }); const paths: OpenAPI['paths'] = {}; - for (const { url, path } of pathsArray) { - paths[url] = path; + for (const { url, method, path } of pathsArray) { + if (!paths[url]) paths[url] = {}; + if (method === 'get') + paths[url].get = path; + else + paths[url].post = path; } return { openapi: "3.1.2", @@ -226,16 +271,28 @@ export function makeFailureResponse(status: 400 | 401 | 403 | 404 | 500, erro return { success: false, status, error }; } +/** z.object({ example: z.(...), hello: z.number(), ... }) */ +export type StandardZodObject = z.ZodObject>; + +export const emptyFormat = { + params: z.object(), query: z.object(), reqBody: z.unknown(), resBody: z.unknown(), +}; + /** * wrapper around router.get with built in validation and schema recording * * the `req` and `res` objects aren't provided to the passed in handler, if - * you're doing something more complicated just use the router direclty for + * you're doing something more complicated just use the router directly for * now, the functionality needed will be incorporated + * + * type parameters + * - P: path params + * - Q: query params + * - RB: response body */ export function addGetRoute< - P extends z.ZodObject>, - Q extends z.ZodObject>, + P extends StandardZodObject, + Q extends StandardZodObject, RB extends z.ZodType >( router: express.Router, @@ -243,7 +300,7 @@ export function addGetRoute< format: { params: P, query: Q, resBody: RB }, handler: (params: z.infer

, query: z.infer) => HandlerReturn>, docs?: { - /** a short description of what is route does */ + /** a short description of what is route does, becomes the title */ summary?: string, /** a longer explanation, commonmark accepted */ description?: string, @@ -256,7 +313,7 @@ export function addGetRoute< router, method: 'get', pathSuffix: path, params: paramsSchema.shape, query: querySchema.shape, resBody: resBodySchema, summary: docs?.summary ?? "", description: docs?.description ?? "", - }) + }); } router.get(path, (req: express.Request, res: express.Response | { error: string }>) => { @@ -293,5 +350,76 @@ export function addGetRoute< /** * wrapper around router.post with built in validation and schema recording - * TODO: make this + * + * the `req` and `res` objects aren't provided to the passed in handler, if + * you're doing something more complicated just use the router directly for + * now, the functionality needed will be incorporated + * + * type parameters + * - P: path params + * - Q: query params + * - B: request body + * - RB: response body */ +export function addPostRoute< + P extends StandardZodObject, + Q extends StandardZodObject, + B extends z.ZodType, + RB extends z.ZodType, +>( + router: express.Router, + path: string, + format: { params: P, query: Q, reqBody: B, resBody: RB }, + handler: (params: z.infer

, query: z.infer, body: z.infer) => HandlerReturn>, + docs?: { + /** a short description of what is route does, becomes the title */ + summary?: string, + /** a longer explanation, commonmark accepted */ + description?: string, + }, +) { + const { params: paramsSchema, query: querySchema, reqBody: reqBodySchema, resBody: resBodySchema } = format; + + if (reflection) { + info.routes.push({ + router, method: 'post', pathSuffix: path, + params: paramsSchema.shape, query: querySchema.shape, reqBody: reqBodySchema, resBody: resBodySchema, + summary: docs?.summary ?? "", description: docs?.description ?? "", + }); + } + + router.post(path, (req, res: express.Response | { error: string }>) => { + const { status, json } = determineResponse(req); + res.status(status).json(json); + }); + + const determineResponse = (req: express.Request): { status: number, json: z.infer | { error: string } } => { + let params = paramsSchema.safeParse(req.params); + if (params.error) { + return { status: 400, json: { error: "invalid path params: " + params.error.message } }; + } + let query = querySchema.safeParse(req.query); + if (query.error) { + return { status: 400, json: { error: "invalid query params: " + query.error.message } }; + } + let body = reqBodySchema.safeParse(req.body); + if (body.error) { + return { status: 400, json: { error: "invalid body: " + body.error.message } }; + } + try { + const result = handler(params.data, query.data, body.data); + if (result.success) { + return { status: result.status, json: result.json }; + } else { + return { status: result.status, json: { error: result.error } }; + } + } catch (e) { + console.error(`uncaught exception in wrapped route: ${e}`) + if (e instanceof Error) { + return { status: 500, json: { error: e.message } } + } else { + return { status: 500, json: { error: JSON.stringify(e) } } + } + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 3422bf1..88b09c0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,12 @@ { "compilerOptions": { "target": "es2021", - "module": "esnext", - "moduleResolution": "bundler", + "module": "preserve", "esModuleInterop": true, "resolveJsonModule": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, - // "baseUrl": "./", "paths": { "@/*": [ "./src/*" From f8cbf8625ae4ebf711f4561f3457ef92eee91805 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sun, 12 Jul 2026 12:32:57 -0700 Subject: [PATCH 04/19] feat(api): support having no req/res bodies Also reordered the definitions so that the main api is together and closer to the top + fixed a bug where req & res bodies were mixed up. --- .gitignore | 3 +- src/routes/documented.ts | 408 ++++++++++++++++++++------------------- 2 files changed, 215 insertions(+), 196 deletions(-) diff --git a/.gitignore b/.gitignore index f4b55a4..f3b0872 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ package-lock.json .env .vscode/ src/assets/walkingCache.json -/docs/ \ No newline at end of file +/docs/ +*.log diff --git a/src/routes/documented.ts b/src/routes/documented.ts index a3f2012..322f92a 100644 --- a/src/routes/documented.ts +++ b/src/routes/documented.ts @@ -26,6 +26,200 @@ import { JSONSchema, ToJSONSchemaParams } from 'zod/v4/core'; /** is reflection enabled? */ export const reflection = true; + +// === interface for people defining apis === + +export function addRouter(app: express.Express, route: string, router: express.Router) { + if (reflection) { + info.routers.push({ route, router }); + } + app.use(route, router); +} + +/** + * feel free to add more codes here and to the make*[a-z]Response functions as you need them + */ +export type HandlerReturn = { + success: true, status: 200 | 201 | 202 | 203 | 205, json: T +} | { + success: false, status: 400 | 401 | 403 | 404 | 500, error: string +}; + +/** + * helper function that should avoid weird typechecker issues + */ +export function makeSuccessResponse(status: 200 | 201 | 202 | 203 | 205, json: T): HandlerReturn { + return { success: true, status, json }; +} + +/** + * helper function that should avoid weird typechecker issues + */ +export function makeFailureResponse(status: 400 | 401 | 403 | 404 | 500, error: string): HandlerReturn { + return { success: false, status, error }; +} + +/** z.object({ example: z.(...), hello: z.number(), ... }) */ +export type StandardZodObject = z.ZodObject>; + +export const emptyFormat = { + params: z.object(), query: z.object(), reqBody: z.unknown(), resBody: z.unknown(), +}; + +/** + * wrapper around router.get with built in validation and schema recording + * + * the `req` and `res` objects aren't provided to the passed in handler, if + * you're doing something more complicated just use the router directly for + * now, the functionality needed will be incorporated + * + * type parameters + * - P: path params + * - Q: query params + * - RB: response body + */ +export function addGetRoute< + P extends StandardZodObject, + Q extends StandardZodObject, + RB extends z.ZodType +>( + router: express.Router, + path: string, + format: { params: P, query: Q, resBody: RB }, + handler: (params: z.infer

, query: z.infer) => HandlerReturn>, + docs?: { + /** a short description of what is route does, becomes the title */ + summary?: string, + /** a longer explanation, commonmark accepted */ + description?: string, + }, +) { + const { params: paramsSchema, query: querySchema, resBody: resBodySchema } = format; + + if (reflection) { + info.routes.push({ + router, method: 'get', + pathSuffix: path, + params: paramsSchema.shape, + query: querySchema.shape, + resBody: resBodySchema instanceof z.ZodUnknown ? null : resBodySchema, + summary: docs?.summary ?? "", description: docs?.description ?? "", + }); + } + + router.get(path, (req: express.Request, res: express.Response | { error: string }>) => { + const { status, json } = determineResponse(req); + res.status(status).json(json); + }); + + const determineResponse = (req: express.Request): { status: number, json: z.infer | { error: string } } => { + let params = paramsSchema.safeParse(req.params); + if (params.error) { + return { status: 400, json: { error: "invalid path params: " + params.error.message } }; + } + let query = querySchema.safeParse(req.query); + if (query.error) { + return { status: 400, json: { error: "invalid query params: " + query.error.message } }; + } + try { + const result = handler(params.data, query.data); + if (result.success) { + return { status: result.status, json: result.json }; + } else { + return { status: result.status, json: { error: result.error } }; + } + } catch (e) { + console.error(`uncaught exception in wrapped route: ${e}`) + if (e instanceof Error) { + return { status: 500, json: { error: e.message } } + } else { + return { status: 500, json: { error: JSON.stringify(e) } } + } + } + } +} + +/** + * wrapper around router.post with built in validation and schema recording + * + * the `req` and `res` objects aren't provided to the passed in handler, if + * you're doing something more complicated just use the router directly for + * now, the functionality needed will be incorporated + * + * type parameters + * - P: path params + * - Q: query params + * - B: request body + * - RB: response body + */ +export function addPostRoute< + P extends StandardZodObject, + Q extends StandardZodObject, + B extends z.ZodType, + RB extends z.ZodType, +>( + router: express.Router, + path: string, + format: { params: P, query: Q, reqBody: B, resBody: RB }, + handler: (params: z.infer

, query: z.infer, body: z.infer) => HandlerReturn>, + docs?: { + /** a short description of what is route does, becomes the title */ + summary?: string, + /** a longer explanation, commonmark accepted */ + description?: string, + }, +) { + const { params: paramsSchema, query: querySchema, reqBody: reqBodySchema, resBody: resBodySchema } = format; + + if (reflection) { + info.routes.push({ + router, method: 'post', pathSuffix: path, + params: paramsSchema.shape, + query: querySchema.shape, + reqBody: reqBodySchema instanceof z.ZodUnknown ? null : reqBodySchema, + resBody: resBodySchema instanceof z.ZodUnknown ? null : resBodySchema, + summary: docs?.summary ?? "", description: docs?.description ?? "", + }); + } + + router.post(path, (req, res: express.Response | { error: string }>) => { + const { status, json } = determineResponse(req); + res.status(status).json(json); + }); + + const determineResponse = (req: express.Request): { status: number, json: z.infer | { error: string } } => { + let params = paramsSchema.safeParse(req.params); + if (params.error) { + return { status: 400, json: { error: "invalid path params: " + params.error.message } }; + } + let query = querySchema.safeParse(req.query); + if (query.error) { + return { status: 400, json: { error: "invalid query params: " + query.error.message } }; + } + let body = reqBodySchema.safeParse(req.body); + if (body.error) { + return { status: 400, json: { error: "invalid body: " + body.error.message } }; + } + try { + const result = handler(params.data, query.data, body.data); + if (result.success) { + return { status: result.status, json: result.json }; + } else { + return { status: result.status, json: { error: result.error } }; + } + } catch (e) { + console.error(`uncaught exception in wrapped route: ${e}`) + if (e instanceof Error) { + return { status: 500, json: { error: e.message } } + } else { + return { status: 500, json: { error: JSON.stringify(e) } } + } + } + } +} + +// === end of interface for api defining === + const info: ReflectionInfoRaw = { routers: [], routes: [] @@ -44,8 +238,8 @@ interface ReflectionInfoRaw { description: string, params: Record, query: Record, - resBody: z.ZodType, - } & ({ method: 'get' } | { method: 'post', reqBody: z.ZodType })>, + resBody: z.ZodType | null, + } & ({ method: 'get' } | { method: 'post', reqBody: z.ZodType | null })>, }; interface ReflectionInfo { @@ -55,7 +249,7 @@ interface ReflectionInfo { description: string, params: Record, query: Record, - resBody: JSONSchema.BaseSchema, + resBody: JSONSchema.BaseSchema | null, } & ({ method: 'get' } | { method: 'post', reqBody: JSONSchema.BaseSchema | null })>, defs: Record, }; @@ -72,7 +266,7 @@ interface OpenAPIPathCommon { responses: { "2XX": { description: "success", - content: { + content?: { "application/json": { schema: JSONSchema.JSONSchema, } @@ -84,7 +278,7 @@ interface OpenAPIPathCommon { interface OpenAPIGetPath extends OpenAPIPathCommon { }; interface OpenAPIPostPath extends OpenAPIPathCommon { - requestBody: { + requestBody?: { content: { "application/json": { schema: JSONSchema.JSONSchema, @@ -151,7 +345,7 @@ function finalize(info: ReflectionInfoRaw): ReflectionInfo { path, params: finalParams, query: finalQuery, - resBody: fixSchema(route.resBody.toJSONSchema(schemaOpts)), + resBody: route.resBody === null ? null : fixSchema(route.resBody.toJSONSchema(schemaOpts)), summary: route.summary, description: route.description, }; @@ -163,15 +357,17 @@ function finalize(info: ReflectionInfoRaw): ReflectionInfo { resultRoutes.push({ ...common, method: 'post', - reqBody: fixSchema(route.reqBody.toJSONSchema(schemaOpts)), + reqBody: route.reqBody === null ? null : fixSchema(route.reqBody.toJSONSchema(schemaOpts)), }); - model[path + ' reqBody'] = route.resBody; + if (route.reqBody) + model[path + ' reqBody'] = route.reqBody; break; default: // TODO: use eslint exhaustiveness checking const _: never = route; } - model[path + ' resBody'] = route.resBody; + if (route.resBody) + model[path + ' resBody'] = route.resBody; } return { routes: resultRoutes, @@ -191,12 +387,13 @@ function makeOpenAPI(info: ReflectionInfo): OpenAPI { for (const name in route.query) { parameters.push({ name: name, in: 'query', required: true, schema: route.query[name] }); } + const content = route.resBody === null + ? undefined + : { 'application/json': { schema: route.resBody } }; const responses: OpenAPIGetPath['responses'] = { '2XX': { description: 'success', - content: { - 'application/json': { schema: route.resBody } - } + content, } }; const common: OpenAPIPathCommon = { @@ -209,9 +406,12 @@ function makeOpenAPI(info: ReflectionInfo): OpenAPI { case 'get': return { url: route.path, method: 'get', path: common }; case 'post': + const requestBody = route.reqBody == null + ? undefined + : { content: { "application/json": { schema: route.reqBody } }, required: true }; return { url: route.path, method: 'post', path: { - requestBody: { content: { "application/json": { schema: route.reqBody } } }, ...common + requestBody, ...common } }; } @@ -241,185 +441,3 @@ export function dumpReflectionInfo() { console.log(JSON.stringify(openAPI, null, 4)); } -export function addRouter(app: express.Express, route: string, router: express.Router) { - if (reflection) { - info.routers.push({ route, router }); - } - app.use(route, router); -} - -/** - * feel free to add more codes here and to the make*[a-z]Response functions as you need them - */ -export type HandlerReturn = { - success: true, status: 200 | 201 | 202 | 203 | 205, json: T -} | { - success: false, status: 400 | 401 | 403 | 404 | 500, error: string -}; - -/** - * helper function that should avoid weird typechecker issues - */ -export function makeSuccessResponse(status: 200 | 201 | 202 | 203 | 205, json: T): HandlerReturn { - return { success: true, status, json }; -} - -/** - * helper function that should avoid weird typechecker issues - */ -export function makeFailureResponse(status: 400 | 401 | 403 | 404 | 500, error: string): HandlerReturn { - return { success: false, status, error }; -} - -/** z.object({ example: z.(...), hello: z.number(), ... }) */ -export type StandardZodObject = z.ZodObject>; - -export const emptyFormat = { - params: z.object(), query: z.object(), reqBody: z.unknown(), resBody: z.unknown(), -}; - -/** - * wrapper around router.get with built in validation and schema recording - * - * the `req` and `res` objects aren't provided to the passed in handler, if - * you're doing something more complicated just use the router directly for - * now, the functionality needed will be incorporated - * - * type parameters - * - P: path params - * - Q: query params - * - RB: response body - */ -export function addGetRoute< - P extends StandardZodObject, - Q extends StandardZodObject, - RB extends z.ZodType ->( - router: express.Router, - path: string, - format: { params: P, query: Q, resBody: RB }, - handler: (params: z.infer

, query: z.infer) => HandlerReturn>, - docs?: { - /** a short description of what is route does, becomes the title */ - summary?: string, - /** a longer explanation, commonmark accepted */ - description?: string, - }, -) { - const { params: paramsSchema, query: querySchema, resBody: resBodySchema } = format; - - if (reflection) { - info.routes.push({ - router, method: 'get', pathSuffix: path, - params: paramsSchema.shape, query: querySchema.shape, resBody: resBodySchema, - summary: docs?.summary ?? "", description: docs?.description ?? "", - }); - } - - router.get(path, (req: express.Request, res: express.Response | { error: string }>) => { - const { status, json } = determineResponse(req); - res.status(status).json(json); - }); - - const determineResponse = (req: express.Request): { status: number, json: z.infer | { error: string } } => { - let params = paramsSchema.safeParse(req.params); - if (params.error) { - return { status: 400, json: { error: "invalid path params: " + params.error.message } }; - } - let query = querySchema.safeParse(req.query); - if (query.error) { - return { status: 400, json: { error: "invalid query params: " + query.error.message } }; - } - try { - const result = handler(params.data, query.data); - if (result.success) { - return { status: result.status, json: result.json }; - } else { - return { status: result.status, json: { error: result.error } }; - } - } catch (e) { - console.error(`uncaught exception in wrapped route: ${e}`) - if (e instanceof Error) { - return { status: 500, json: { error: e.message } } - } else { - return { status: 500, json: { error: JSON.stringify(e) } } - } - } - } -} - -/** - * wrapper around router.post with built in validation and schema recording - * - * the `req` and `res` objects aren't provided to the passed in handler, if - * you're doing something more complicated just use the router directly for - * now, the functionality needed will be incorporated - * - * type parameters - * - P: path params - * - Q: query params - * - B: request body - * - RB: response body - */ -export function addPostRoute< - P extends StandardZodObject, - Q extends StandardZodObject, - B extends z.ZodType, - RB extends z.ZodType, ->( - router: express.Router, - path: string, - format: { params: P, query: Q, reqBody: B, resBody: RB }, - handler: (params: z.infer

, query: z.infer, body: z.infer) => HandlerReturn>, - docs?: { - /** a short description of what is route does, becomes the title */ - summary?: string, - /** a longer explanation, commonmark accepted */ - description?: string, - }, -) { - const { params: paramsSchema, query: querySchema, reqBody: reqBodySchema, resBody: resBodySchema } = format; - - if (reflection) { - info.routes.push({ - router, method: 'post', pathSuffix: path, - params: paramsSchema.shape, query: querySchema.shape, reqBody: reqBodySchema, resBody: resBodySchema, - summary: docs?.summary ?? "", description: docs?.description ?? "", - }); - } - - router.post(path, (req, res: express.Response | { error: string }>) => { - const { status, json } = determineResponse(req); - res.status(status).json(json); - }); - - const determineResponse = (req: express.Request): { status: number, json: z.infer | { error: string } } => { - let params = paramsSchema.safeParse(req.params); - if (params.error) { - return { status: 400, json: { error: "invalid path params: " + params.error.message } }; - } - let query = querySchema.safeParse(req.query); - if (query.error) { - return { status: 400, json: { error: "invalid query params: " + query.error.message } }; - } - let body = reqBodySchema.safeParse(req.body); - if (body.error) { - return { status: 400, json: { error: "invalid body: " + body.error.message } }; - } - try { - const result = handler(params.data, query.data, body.data); - if (result.success) { - return { status: result.status, json: result.json }; - } else { - return { status: result.status, json: { error: result.error } }; - } - } catch (e) { - console.error(`uncaught exception in wrapped route: ${e}`) - if (e instanceof Error) { - return { status: 500, json: { error: e.message } } - } else { - return { status: 500, json: { error: JSON.stringify(e) } } - } - } - } -} From f3eaefad2ab2a8b5fad0e3ba47f9c02eabb65553 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sun, 2 Aug 2026 11:43:21 -0700 Subject: [PATCH 05/19] fix(docu): clean up openapi output, adjust comment --- src/routes/documented.ts | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/routes/documented.ts b/src/routes/documented.ts index 322f92a..675f54b 100644 --- a/src/routes/documented.ts +++ b/src/routes/documented.ts @@ -37,7 +37,7 @@ export function addRouter(app: express.Express, route: string, router: express.R } /** - * feel free to add more codes here and to the make*[a-z]Response functions as you need them + * feel free to add more codes here and to the make[A-Za-z]*Response functions as you need them */ export type HandlerReturn = { success: true, status: 200 | 201 | 202 | 203 | 205, json: T @@ -303,16 +303,30 @@ interface OpenAPI { function finalize(info: ReflectionInfoRaw): ReflectionInfo { // replace $def with components/schemas - const fixSchema = (s: T): T => { + const fixSchema = (s: T, shouldStripDefs: boolean): T => { + stripExtraKeys(s, shouldStripDefs); if (typeof s !== 'object' || !s) return s; if ('$ref' in s && typeof s.$ref == 'string') s.$ref = s.$ref.replace('$defs', 'components/schemas'); for (const v of Object.values(s)) { - fixSchema(v); + fixSchema(v, shouldStripDefs); } return s; }; + const stripExtraKeys = (s: T, shouldStripDefs: boolean): T => { + if (typeof s !== 'object' || !s) return s; + if (shouldStripDefs && '$defs' in s) { + s['$defs'] = undefined; + } + if ('$schema' in s) s['$schema'] = undefined; + if ('id' in s) s['id'] = undefined; + for (const v of Object.values(s)) { + stripExtraKeys(v, shouldStripDefs); + } + return s; + } + // TODO: try output first then fallback to input const schemaOpts: ToJSONSchemaParams = { // reused: 'ref', @@ -333,19 +347,19 @@ function finalize(info: ReflectionInfoRaw): ReflectionInfo { for (const param in route.params) { const zodSchema = route.params[param]; model[path + ' params ' + param] = zodSchema; - finalParams[param] = fixSchema(zodSchema.toJSONSchema(schemaOpts)); + finalParams[param] = fixSchema(zodSchema.toJSONSchema(schemaOpts), true); } const finalQuery: Record = {}; for (const key in route.query) { const zodSchema = route.query[key]; model[path + '?' + key] = zodSchema; - finalQuery[key] = fixSchema(zodSchema.toJSONSchema(schemaOpts)); + finalQuery[key] = fixSchema(zodSchema.toJSONSchema(schemaOpts), true); } const common = { path, params: finalParams, query: finalQuery, - resBody: route.resBody === null ? null : fixSchema(route.resBody.toJSONSchema(schemaOpts)), + resBody: route.resBody === null ? null : fixSchema(route.resBody.toJSONSchema(schemaOpts), true), summary: route.summary, description: route.description, }; @@ -357,7 +371,9 @@ function finalize(info: ReflectionInfoRaw): ReflectionInfo { resultRoutes.push({ ...common, method: 'post', - reqBody: route.reqBody === null ? null : fixSchema(route.reqBody.toJSONSchema(schemaOpts)), + reqBody: route.reqBody === null + ? null + : fixSchema(route.reqBody.toJSONSchema(schemaOpts), true), }); if (route.reqBody) model[path + ' reqBody'] = route.reqBody; @@ -371,7 +387,7 @@ function finalize(info: ReflectionInfoRaw): ReflectionInfo { } return { routes: resultRoutes, - defs: fixSchema(z.object(model).toJSONSchema(schemaOpts)).$defs ?? {}, + defs: fixSchema(z.object(model).toJSONSchema(schemaOpts), false).$defs ?? {}, }; } From d7246b5ccfab7289a082ce3bb8b9597ef549ffd9 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Wed, 22 Jul 2026 13:13:57 -0700 Subject: [PATCH 06/19] feat: test stubs, output to file, flesh out tsdoc comments --- src/app.ts | 9 ++- src/routes/api.ts | 16 ++-- src/routes/documented.ts | 158 ++++++++++++++++++++++++++++----------- test/documented.test.ts | 78 +++++++++++++++++++ 4 files changed, 207 insertions(+), 54 deletions(-) create mode 100644 test/documented.test.ts diff --git a/src/app.ts b/src/app.ts index f743a3d..c5508a6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,12 +1,12 @@ import express from "express"; import mbus from "./routes/api" -import { addRouter, dumpReflectionInfo, reflection } from "./routes/documented"; +import * as documented from "./routes/documented"; const app = express(); app.use(express.json()); -addRouter(app, "/mbus/api/v3", mbus); +documented.addRouter(documented.globalContext, app, "/mbus/api/v3", mbus); app.use("/docs", express.static("docs")); const PORT = process.env.PORT || 3000; @@ -14,6 +14,7 @@ const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); - if (reflection) - dumpReflectionInfo(); + if (documented.ENABLED) { + documented.outputDocsFor(documented.globalContext); + } }); \ No newline at end of file diff --git a/src/routes/api.ts b/src/routes/api.ts index 0131ee4..bb953c5 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -9,7 +9,7 @@ import * as journeyService from '../services/journey'; import * as reminderService from '../services/reminder'; import * as graphBuilder from '../services/graphBuilder'; import { startBackgroundJobs } from '../jobs'; -import { addGetRoute, addPostRoute, emptyFormat, makeFailureResponse, makeSuccessResponse } from "./documented"; +import * as documented from "./documented"; /** * Express router for the MBus API v3. @@ -488,12 +488,12 @@ router.get('/get-key-stops', getKeyStops); // Notifications / Reminders const SetReminderBody = z.object({ token: z.string(), stpid: z.string(), rtid: z.string(), thresh: z.number() }); -addPostRoute( - router, '/setReminder', { ...emptyFormat, reqBody: SetReminderBody }, +documented.addPostRoute( + documented.globalContext, router, '/setReminder', { ...documented.emptyFormat, reqBody: SetReminderBody }, (_, __, { token, stpid, rtid, thresh }) => { const info = reminderService.infoToUseForRoute(rtid); if (info === null) { - return makeFailureResponse(400, `Invalid route ${rtid}`); + return documented.makeFailureResponse(400, `Invalid route ${rtid}`); } const { reminderSubscriptions, predsByStopId } = info; reminderSubscriptions.add( @@ -503,7 +503,7 @@ addPostRoute( predsByStopId, Date.now(), ); - return makeSuccessResponse(200, {}); + return documented.makeSuccessResponse(200, {}); } ); @@ -564,8 +564,8 @@ const ActiveReminder = z.object({ eta: z.number().nullable(), }).meta({ id: "Reminder" }); -addGetRoute( - router, '/activeReminders/:token', +documented.addGetRoute( + documented.globalContext, router, '/activeReminders/:token', { params: z.object({ token: Token }), query: z.object(), @@ -589,7 +589,7 @@ addGetRoute( .rideReminderSubscriptions .activeRemindersFor(token) .map(subscriptionInfo); - return makeSuccessResponse(200, { reminders: universityReminders.concat(rideReminders) }); + return documented.makeSuccessResponse(200, { reminders: universityReminders.concat(rideReminders) }); }, { summary: "active reminders", diff --git a/src/routes/documented.ts b/src/routes/documented.ts index 675f54b..5f77bd3 100644 --- a/src/routes/documented.ts +++ b/src/routes/documented.ts @@ -1,43 +1,73 @@ /** * Wrappers around stuff you would otherwise do with express but with reflection - * capabilities used for openapi specification generation. + * capabilities used for openapi specification generation and built-in request + * format validation. * * The `req` and `res` objects aren't provided to the passed in handler * functions, if you're doing something more complicated just use the router * directly for now. * * Nested routing not supported yet, but should probably be added since api.ts - * is getting long (or we could separate the functions from the route defintions?). + * is getting long (or we could separate the functions from the route + * defintions). * * Extra functionality can be added as needed. * - * EXAMPLES: + * # Getting Started + * + * ## Defining Routes + * + * Make sure you know how to use Zod, then look into {@link addRouter}, + * {@link addGetRoute}, and {@link addPostRoute}. It would also be useful to + * take a look at {@link HandlerReturn} + remember the existence of + * {@link emptyFormat} and {@link globalContext}. + * + * ## Getting OpenAPI Specs + * + * Look into setting the environment variables `DOCUMENTED` (to anything truthy) + * and `DOCUMENTED_OUTPUT_FILE` (or it will log to the console). Also look at + * {@link globalContext}, {@link docsFor}, and {@link outputDocsFor} * - * TODO: post support [done?] * TODO: add examples - * TODO: support empty request and response bodies + * * TODO: make actually testable? + * * TODO: add tests? + * @module */ +import * as fs from 'node:fs/promises'; + +import dotenv from 'dotenv'; import express from 'express'; import z from 'zod'; import { JSONSchema, ToJSONSchemaParams } from 'zod/v4/core'; -/** is reflection enabled? */ -export const reflection = true; +dotenv.config(); + +export const ENABLED = process.env.DOCUMENTED && true; +const OUTPUT_FILE = process.env.DOCUMENTED_OUTPUT_FILE ?? null // === interface for people defining apis === -export function addRouter(app: express.Express, route: string, router: express.Router) { - if (reflection) { - info.routers.push({ route, router }); +/** + * Wrapper around `express.Express.use`, instead something like + * `app.use("/api", router)` you'd call + * `addRouter(someContext, app, "/api", router)`. + */ +export function addRouter(ctx: Context, app: express.Express, route: string, router: express.Router) { + if (ENABLED) { + ctx.routers.push({ route, router }); } app.use(route, router); } /** - * feel free to add more codes here and to the make[A-Za-z]*Response functions as you need them + * You should genrally use either {@link makeSuccessResponse} or + * {@link makeFailureResponse} to construct this. + * + * Feel free to add more codes here and to the make[A-Za-z]*Response functions + * as you need them. */ export type HandlerReturn = { success: true, status: 200 | 201 | 202 | 203 | 205, json: T @@ -59,30 +89,46 @@ export function makeFailureResponse(status: 400 | 401 | 403 | 404 | 500, erro return { success: false, status, error }; } -/** z.object({ example: z.(...), hello: z.number(), ... }) */ +/** + * A type representing a Zod object (i.e. `z.object(...)`) used normally. + */ export type StandardZodObject = z.ZodObject>; +/** + * Meant to be used along with the spread operator to fill out format fields + * that aren't cared about. + */ export const emptyFormat = { params: z.object(), query: z.object(), reqBody: z.unknown(), resBody: z.unknown(), }; /** - * wrapper around router.get with built in validation and schema recording + * Wrapper around router.get with built in validation and schema recording + * + * The `req` and `res` objects aren't provided to the passed in handler, if + * you're doing something more complicated (e.g. using headers) just use the + * router directly for now, the functionality needed could be incorporated in + * the future. * - * the `req` and `res` objects aren't provided to the passed in handler, if - * you're doing something more complicated just use the router directly for - * now, the functionality needed will be incorporated + * @typeParam P - path parameters as a zod object + * @typeParam Q - query parameters as a zod object + * @typeParam RB - response body as a zod type * - * type parameters - * - P: path params - * - Q: query params - * - RB: response body + * @param ctx - which context to place this route in, see `globalContext` + * @param router - express router + * @param path - path the route happens, same format as used in express but + * avoid features not supported in openapi (e.g. advanced path matchers) + * @param format - zod schemas of the parts of the request and response, use + * `emptyFormat` to fill in default values + * @param handler - route handler + * @param docs - information that should end up in the openapi spec */ export function addGetRoute< P extends StandardZodObject, Q extends StandardZodObject, RB extends z.ZodType >( + ctx: Context, router: express.Router, path: string, format: { params: P, query: Q, resBody: RB }, @@ -96,8 +142,8 @@ export function addGetRoute< ) { const { params: paramsSchema, query: querySchema, resBody: resBodySchema } = format; - if (reflection) { - info.routes.push({ + if (ENABLED) { + ctx.routes.push({ router, method: 'get', pathSuffix: path, params: paramsSchema.shape, @@ -140,17 +186,22 @@ export function addGetRoute< } /** - * wrapper around router.post with built in validation and schema recording + * Wrapper around router.post with built in validation and schema recording, + * more details can be found in {@link addGetRoute}. * - * the `req` and `res` objects aren't provided to the passed in handler, if - * you're doing something more complicated just use the router directly for - * now, the functionality needed will be incorporated + * @typeParam P - path params + * @typeParam Q - query params + * @typeParam B - request body + * @typeParam RB - response body * - * type parameters - * - P: path params - * - Q: query params - * - B: request body - * - RB: response body + * @param ctx - which context to place this route in, see `globalContext` + * @param router - express router + * @param path - path the route happens, same format as used in express but + * avoid features not supported in openapi (e.g. advanced path matchers) + * @param format - zod schemas of the parts of the request and response, use + * `emptyFormat` to fill in default values + * @param handler - route handler + * @param docs - information that should end up in the openapi spec */ export function addPostRoute< P extends StandardZodObject, @@ -158,6 +209,7 @@ export function addPostRoute< B extends z.ZodType, RB extends z.ZodType, >( + ctx: Context, router: express.Router, path: string, format: { params: P, query: Q, reqBody: B, resBody: RB }, @@ -171,8 +223,8 @@ export function addPostRoute< ) { const { params: paramsSchema, query: querySchema, reqBody: reqBodySchema, resBody: resBodySchema } = format; - if (reflection) { - info.routes.push({ + if (ENABLED) { + ctx.routes.push({ router, method: 'post', pathSuffix: path, params: paramsSchema.shape, query: querySchema.shape, @@ -220,15 +272,22 @@ export function addPostRoute< // === end of interface for api defining === -const info: ReflectionInfoRaw = { +/** + * The context you should probably be using for everything unless writing a + * test. + */ +export const globalContext: Context = { routers: [], routes: [] }; +/** Where api route info is aggregated */ +export type Context = ReflectionInfoRaw; + /** - * doc descriptions passed as data in summary and description fields + * @internal */ -interface ReflectionInfoRaw { +export interface ReflectionInfoRaw { routers: Array<{ route: string, router: express.Router }>, /** full routes along with req+res schemas, routes are incomplete until info is finalized */ routes: Array<{ @@ -275,9 +334,9 @@ interface OpenAPIPathCommon { }, }; -interface OpenAPIGetPath extends OpenAPIPathCommon { }; +export interface OpenAPIGetPath extends OpenAPIPathCommon { }; -interface OpenAPIPostPath extends OpenAPIPathCommon { +export interface OpenAPIPostPath extends OpenAPIPathCommon { requestBody?: { content: { "application/json": { @@ -289,7 +348,7 @@ interface OpenAPIPostPath extends OpenAPIPathCommon { }; /** the subset of the openapi format(s) we are concerned with generating */ -interface OpenAPI { +export interface OpenAPI { openapi: "3.1.2", info: { title: string, @@ -451,9 +510,24 @@ function makeOpenAPI(info: ReflectionInfo): OpenAPI { } } -export function dumpReflectionInfo() { - const finalized = finalize(info); +/** Get the OpenAPI spec as a structured object. */ +export function docsFor(ctx: Context) { + const finalized = finalize(ctx); const openAPI = makeOpenAPI(finalized); - console.log(JSON.stringify(openAPI, null, 4)); + return openAPI; +} + +/** + * Output the OpenAPI spec to the file specified by the environment, or to the + * console if this isn't set. + */ +export async function outputDocsFor(ctx: Context) { + console.log('outputting docs...'); + const openAPI = docsFor(ctx); + const output = JSON.stringify(openAPI, null, 4); + if (OUTPUT_FILE) + await fs.writeFile(OUTPUT_FILE, output); + else + console.log(output); } diff --git a/test/documented.test.ts b/test/documented.test.ts new file mode 100644 index 0000000..59ddd5e --- /dev/null +++ b/test/documented.test.ts @@ -0,0 +1,78 @@ +import { expect, it } from "vitest"; + +it('should handle path params (GET)', () => { + // correct value goes through + // incorrect value is caught + // shows up in docs + expect(true).toBe(false); +}) + +it('should handle query params (GET)', () => { + // correct value goes through + // incorrect value is caught + // shows up in docs + expect(true).toBe(false); +}) + +it('should handle response bodies (GET)', () => { + // correct value goes through + // incorrect value is caught + // shows up in docs + // can be empty + expect(true).toBe(false); +}) + +it('should handle path params (POST)', () => { + // correct value goes through + // incorrect value is caught + // shows up in docs + expect(true).toBe(false); +}) + +it('should handle query params (POST)', () => { + // correct value goes through + // incorrect value is caught + // shows up in docs + expect(true).toBe(false); +}) + +it('should handle request bodies (POST)', () => { + // correct value goes through + // incorrect value is caught + // shows up in docs + // can be empty + expect(true).toBe(false); +}) + +it('should handle response bodies (POST)', () => { + // correct value goes through + // incorrect value is caught + // shows up in docs + // can be empty + expect(true).toBe(false); +}) + +it('should handle zod coerce types', () => { + // type is correct + // docs don't error + expect(true).toBe(false); +}); + +it('should handle zod pipe/transform types', () => { + // type is correct + // docs don't error + expect(true).toBe(false); +}); + +it('should surface type descriptions & names', () => { + expect(true).toBe(false); +}); + +it('should surface route descriptions & names', () => { + expect(true).toBe(false); +}); + +it('should have a stable output', () => { + expect(true).toBe(false); +}); + From 268a7a21ec2449f00d36b34fdc8e58e86c0e44c8 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Wed, 22 Jul 2026 14:12:18 -0700 Subject: [PATCH 07/19] ci: generate and upload openapi specs --- .github/workflows/ci.yml | 29 ++++++++++++++++++++++++++++- src/routes/documented.ts | 13 +++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64f2ccf..1eb31a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: - name: Build Docs run: npx typedoc --entryPointStrategy expand ./src --treatWarningsAsErrors working-directory: ${{ github.workspace }} - - name: Sync files + - name: Sync Files uses: SamKirkland/FTP-Deploy-Action@v4.4.0 with: server: ${{ secrets.FTP_SERVER }} @@ -68,3 +68,30 @@ jobs: password: ${{ secrets.FTP_PASSWORD }} local-dir: ${{ github.workspace }}/docs/ server-dir: ${{ github.ref }}/typedoc/ + + OpenAPI: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v6 + - name: NPM Install + run: npm i + working-directory: ${{ github.workspace }} + - name: Build Spec + run: | + export DOCUMENTED=1 + export DOCUMENTED_OUTPUT_FILE=openapi/spec.json + export DOCUMENTED_EXIT_ON_OUTPUT=1 + mkdir openapi + npm start + working-directory: ${{ github.workspace }} + - name: Sync Files + uses: SamKirkland/FTP-Deploy-Action@v4.4.0 + with: + server: ${{ secrets.FTP_SERVER }} + port: ${{ secrets.FTP_PORT }} + username: ${{ secrets.FTP_USERNAME }} + password: ${{ secrets.FTP_PASSWORD }} + local-dir: ${{ github.workspace }}/openapi/ + server-dir: ${{ github.ref }}/openapi/ + diff --git a/src/routes/documented.ts b/src/routes/documented.ts index 5f77bd3..7a72ddb 100644 --- a/src/routes/documented.ts +++ b/src/routes/documented.ts @@ -24,9 +24,10 @@ * * ## Getting OpenAPI Specs * - * Look into setting the environment variables `DOCUMENTED` (to anything truthy) - * and `DOCUMENTED_OUTPUT_FILE` (or it will log to the console). Also look at - * {@link globalContext}, {@link docsFor}, and {@link outputDocsFor} + * Look into setting the environment variables `DOCUMENTED` (to anything + * truthy), `DOCUMENTED_OUTPUT_FILE` (or it will log to the console), and + * `DOCUMENTED_EXIT_ON_OUTPUT`. Also look at {@link globalContext}, + * {@link docsFor}, and {@link outputDocsFor}. * * TODO: add examples * @@ -42,11 +43,13 @@ import dotenv from 'dotenv'; import express from 'express'; import z from 'zod'; import { JSONSchema, ToJSONSchemaParams } from 'zod/v4/core'; +import { exit } from 'node:process'; dotenv.config(); export const ENABLED = process.env.DOCUMENTED && true; const OUTPUT_FILE = process.env.DOCUMENTED_OUTPUT_FILE ?? null +const EXIT_ON_OUTPUT = process.env.DOCUMENTED_EXIT_ON_OUTPUT && true; // === interface for people defining apis === @@ -519,7 +522,7 @@ export function docsFor(ctx: Context) { /** * Output the OpenAPI spec to the file specified by the environment, or to the - * console if this isn't set. + * console if this isn't set. Will also exit the process if configured to do so. */ export async function outputDocsFor(ctx: Context) { console.log('outputting docs...'); @@ -529,5 +532,7 @@ export async function outputDocsFor(ctx: Context) { await fs.writeFile(OUTPUT_FILE, output); else console.log(output); + if (EXIT_ON_OUTPUT) + exit(0); } From b2d9864586f4c4b04465417158aec6272d3db7c1 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sun, 2 Aug 2026 11:49:53 -0700 Subject: [PATCH 08/19] refactor(docu): improve testability --- src/routes/documented.ts | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/routes/documented.ts b/src/routes/documented.ts index 7a72ddb..9ebf766 100644 --- a/src/routes/documented.ts +++ b/src/routes/documented.ts @@ -30,9 +30,7 @@ * {@link docsFor}, and {@link outputDocsFor}. * * TODO: add examples - * - * TODO: make actually testable? - * + * TODO: add tests? * @module */ @@ -125,6 +123,9 @@ export const emptyFormat = { * `emptyFormat` to fill in default values * @param handler - route handler * @param docs - information that should end up in the openapi spec + * + * @returns the handler function that got passed to `router.get` internally, + * to be used in testing */ export function addGetRoute< P extends StandardZodObject, @@ -156,12 +157,12 @@ export function addGetRoute< }); } - router.get(path, (req: express.Request, res: express.Response | { error: string }>) => { + router.get(path, (req: ExpressRequest, res: express.Response | { error: string }>) => { const { status, json } = determineResponse(req); res.status(status).json(json); }); - const determineResponse = (req: express.Request): { status: number, json: z.infer | { error: string } } => { + const determineResponse = (req: ExpressRequest): { status: number, json: z.infer | { error: string } } => { let params = paramsSchema.safeParse(req.params); if (params.error) { return { status: 400, json: { error: "invalid path params: " + params.error.message } }; @@ -186,6 +187,7 @@ export function addGetRoute< } } } + return determineResponse; } /** @@ -205,6 +207,9 @@ export function addGetRoute< * `emptyFormat` to fill in default values * @param handler - route handler * @param docs - information that should end up in the openapi spec + * + * @returns the handler function that got passed to `router.post` internally, + * to be used in testing */ export function addPostRoute< P extends StandardZodObject, @@ -242,7 +247,7 @@ export function addPostRoute< res.status(status).json(json); }); - const determineResponse = (req: express.Request): { status: number, json: z.infer | { error: string } } => { + const determineResponse = (req: ExpressRequest): { status: number, json: z.infer | { error: string } } => { let params = paramsSchema.safeParse(req.params); if (params.error) { return { status: 400, json: { error: "invalid path params: " + params.error.message } }; @@ -271,6 +276,7 @@ export function addPostRoute< } } } + return determineResponse; } // === end of interface for api defining === @@ -279,10 +285,14 @@ export function addPostRoute< * The context you should probably be using for everything unless writing a * test. */ -export const globalContext: Context = { - routers: [], - routes: [] -}; +export const globalContext: Context = newContext(); + +/** + * Returns an independent context. + */ +export function newContext() { + return { routers: [], routes: [] }; +} /** Where api route info is aggregated */ export type Context = ReflectionInfoRaw; @@ -363,6 +373,15 @@ export interface OpenAPI { paths: Record*/>, } +/** + * the parts of an express request that are relevant for mocking during tests + */ +export interface ExpressRequest { + params: express.Request['params'] + query: express.Request['query'] + body: express.Request['body'] +} + function finalize(info: ReflectionInfoRaw): ReflectionInfo { // replace $def with components/schemas const fixSchema = (s: T, shouldStripDefs: boolean): T => { From d533675c58ac0b92adc02565f4baaf9d23f20007 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sun, 2 Aug 2026 11:52:21 -0700 Subject: [PATCH 09/19] test(docu): add some tests --- src/routes/documented.ts | 2 - test/documented.test.ts | 205 +++++++++++++++++++++++++++++++++------ 2 files changed, 174 insertions(+), 33 deletions(-) diff --git a/src/routes/documented.ts b/src/routes/documented.ts index 9ebf766..c152cd8 100644 --- a/src/routes/documented.ts +++ b/src/routes/documented.ts @@ -30,8 +30,6 @@ * {@link docsFor}, and {@link outputDocsFor}. * * TODO: add examples - - * TODO: add tests? * @module */ diff --git a/test/documented.test.ts b/test/documented.test.ts index 59ddd5e..65fc75c 100644 --- a/test/documented.test.ts +++ b/test/documented.test.ts @@ -1,78 +1,221 @@ import { expect, it } from "vitest"; +import * as d from '@/routes/documented'; + +import express from 'express'; +import z from 'zod'; it('should handle path params (GET)', () => { - // correct value goes through - // incorrect value is caught - // shows up in docs - expect(true).toBe(false); -}) + testCase( + 'get', + '/root', '/path/{item}', + { ...d.emptyFormat, params: z.object({ item: z.string() }) }, + { params: { item: 'five' }, query: {}, body: {} }, + { params: { wrong: 'five' }, query: {}, body: {} }, + (correct) => expect(correct) + .toMatchInlineSnapshot(` + { + "params": { + "item": "five", + }, + "query": {}, + } + `), + (incorrect) => expect(incorrect) + .toMatchInlineSnapshot(` + "invalid path params: [ + { + "expected": "string", + "code": "invalid_type", + "path": [ + "item" + ], + "message": "Invalid input: expected string, received undefined" + } + ]" + `), + (spec) => expect(spec.paths['/root/path/{item}'].get?.parameters[0]) + .toMatchInlineSnapshot(` + { + "in": "path", + "name": "item", + "required": true, + "schema": { + "type": "string", + }, + } + `), + ); +}); it('should handle query params (GET)', () => { - // correct value goes through - // incorrect value is caught - // shows up in docs - expect(true).toBe(false); -}) + testCase( + 'get', '', '/api', + { query: z.object({ field: z.number() })}, + { query: { field: "-2" } }, + { query: { field: "no" } }, + (json) => expect(json).toMatchInlineSnapshot(), + (error) => expect(error).toMatchInlineSnapshot(), + (spec) => expect(spec.paths['/api'].get?.parameters[0]).toMatchInlineSnapshot() + ) +}); it('should handle response bodies (GET)', () => { // correct value goes through // incorrect value is caught // shows up in docs // can be empty - expect(true).toBe(false); -}) + expect(true).toBe(true); +}); it('should handle path params (POST)', () => { - // correct value goes through - // incorrect value is caught - // shows up in docs - expect(true).toBe(false); -}) + testCase( + 'post', + '/root', '/path/{item}', + { ...d.emptyFormat, params: z.object({ item: z.string() }) }, + { params: { item: 'five' }, query: {}, body: {} }, + { params: { wrong: 'five' }, query: {}, body: {} }, + (correct) => expect(correct) + .toMatchInlineSnapshot(` + { + "body": {}, + "params": { + "item": "five", + }, + "query": {}, + } + `), + (incorrect) => expect(incorrect) + .toMatchInlineSnapshot(` + "invalid path params: [ + { + "expected": "string", + "code": "invalid_type", + "path": [ + "item" + ], + "message": "Invalid input: expected string, received undefined" + } + ]" + `), + (spec) => expect(spec.paths['/root/path/{item}'].post?.parameters[0]) + .toMatchInlineSnapshot(` + { + "in": "path", + "name": "item", + "required": true, + "schema": { + "type": "string", + }, + } + `), + ); +}); it('should handle query params (POST)', () => { - // correct value goes through - // incorrect value is caught - // shows up in docs - expect(true).toBe(false); -}) + testCase( + 'post', '', '/api', + { query: z.object({ field: z.number() })}, + { query: { field: "-2" } }, + { query: { field: "no" } }, + (json) => expect(json).toMatchInlineSnapshot(), + (error) => expect(error).toMatchInlineSnapshot(), + (spec) => expect(spec.paths['/api'].post?.parameters[0]).toMatchInlineSnapshot() + ) +}); it('should handle request bodies (POST)', () => { // correct value goes through // incorrect value is caught // shows up in docs // can be empty - expect(true).toBe(false); -}) + expect(true).toBe(true); +}); it('should handle response bodies (POST)', () => { // correct value goes through // incorrect value is caught // shows up in docs // can be empty - expect(true).toBe(false); -}) + expect(true).toBe(true); +}); + +function testCase( + mode: 'get' | 'post', + base: string, + suffix: string, + format: Partial, + correct: Partial, + incorrect: Partial, + validateCorrect: (json: unknown) => unknown, + validateIncorrect: (error: string) => unknown, + validateSpec: (spec: d.OpenAPI) => unknown, +) { + const app = express(); + const router = express.Router(); + + const ctx = d.newContext(); + d.addRouter(ctx, app, base, router); + const handler = mode === 'get' + ? d.addGetRoute( + ctx, router, suffix, + { ...d.emptyFormat, ...format }, + (params, query) => d.makeSuccessResponse(200, { params, query }) + ) + : d.addPostRoute( + ctx, router, suffix, + { ...d.emptyFormat, ...format }, + (params, query, body) => d.makeSuccessResponse(200, { params, query, body }) + ); + + const defaultResponse: d.ExpressRequest = { query: {}, params: {}, body: {} }; + // correct value goes through? + { + const res = handler({ ...defaultResponse, ...correct }); + expect(res.status).toBe(200); + validateCorrect(res.json); + } + + // incorrect value is caught? + { + const res = handler({ ...defaultResponse, ...incorrect }); + expect(res.status).toBe(400); + validateIncorrect( + typeof res.json === 'object' && res.json && 'error' in res.json && typeof res.json.error === 'string' + ? res.json.error + : '' + ); + } + + // shows up in docs? + const spec = d.docsFor(ctx); + validateSpec(spec); +} + +it('should be able to handle GET & POST to the same path', () => { + expect(true).toBe(true); +}); it('should handle zod coerce types', () => { // type is correct // docs don't error - expect(true).toBe(false); + expect(true).toBe(true); }); it('should handle zod pipe/transform types', () => { // type is correct // docs don't error - expect(true).toBe(false); + expect(true).toBe(true); }); it('should surface type descriptions & names', () => { - expect(true).toBe(false); + expect(true).toBe(true); }); it('should surface route descriptions & names', () => { - expect(true).toBe(false); + expect(true).toBe(true); }); it('should have a stable output', () => { - expect(true).toBe(false); + expect(true).toBe(true); }); From d436fad6d47ff4ee130b6e0d53de150f7c17a7ba Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sun, 2 Aug 2026 11:53:38 -0700 Subject: [PATCH 10/19] fix(docu): adjust oapi output and path/query type --- src/routes/documented.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/routes/documented.ts b/src/routes/documented.ts index c152cd8..5b34764 100644 --- a/src/routes/documented.ts +++ b/src/routes/documented.ts @@ -22,6 +22,17 @@ * take a look at {@link HandlerReturn} + remember the existence of * {@link emptyFormat} and {@link globalContext}. * + * ### Be Careful With Zod Transformations + * The OpenAPI spec will be populated with the output formats of the schemas you + * define routes with. This gives z.coerce the correct behavior and also will + * work with carefully constructed z.pipe chains, but a z.transform without + * further validation through pipe will not work. Uses of z.coerce, z.pipe, and + * z.transform should generally conform to a parse string into primative type + * pattern, and only be used for path and query parameters. Response schemas + * are not guaranteed to be used (currently they aren't but this may change in + * the future) so avoid *any* kind of data transformation in them unless it is + * confirmed that response schemas will be used. + * * ## Getting OpenAPI Specs * * Look into setting the environment variables `DOCUMENTED` (to anything @@ -53,6 +64,9 @@ const EXIT_ON_OUTPUT = process.env.DOCUMENTED_EXIT_ON_OUTPUT && true; * Wrapper around `express.Express.use`, instead something like * `app.use("/api", router)` you'd call * `addRouter(someContext, app, "/api", router)`. + * + * @param route should not have a trailing slash (notably '/' would be + * incorrect, pass '' instead) */ export function addRouter(ctx: Context, app: express.Express, route: string, router: express.Router) { if (ENABLED) { @@ -89,9 +103,10 @@ export function makeFailureResponse(status: 400 | 401 | 403 | 404 | 500, erro } /** - * A type representing a Zod object (i.e. `z.object(...)`) used normally. + * A type representing a Zod object (i.e. `z.object(...)`) where string fields + * like those from query & path parms can get parsed. */ -export type StandardZodObject = z.ZodObject>; +export type StandardZodObject = z.ZodObject>>; /** * Meant to be used along with the spread operator to fill out format fields @@ -396,20 +411,19 @@ function finalize(info: ReflectionInfoRaw): ReflectionInfo { const stripExtraKeys = (s: T, shouldStripDefs: boolean): T => { if (typeof s !== 'object' || !s) return s; if (shouldStripDefs && '$defs' in s) { - s['$defs'] = undefined; + delete s['$defs']; } - if ('$schema' in s) s['$schema'] = undefined; - if ('id' in s) s['id'] = undefined; + if ('$schema' in s) delete s['$schema']; + if ('id' in s) delete s['id']; for (const v of Object.values(s)) { stripExtraKeys(v, shouldStripDefs); } return s; } - // TODO: try output first then fallback to input const schemaOpts: ToJSONSchemaParams = { // reused: 'ref', - io: 'input', + io: 'output', } const resultRoutes: ReflectionInfo['routes'] = []; From 5e71196ad1cc8c0cd0d992bbb25afcf49093000d Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Thu, 23 Jul 2026 14:37:10 -0700 Subject: [PATCH 11/19] feat(docu): improve error ergonomics when calling finalize --- src/routes/documented.ts | 94 +++++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 45 deletions(-) diff --git a/src/routes/documented.ts b/src/routes/documented.ts index 5b34764..a77ad5e 100644 --- a/src/routes/documented.ts +++ b/src/routes/documented.ts @@ -431,52 +431,56 @@ function finalize(info: ReflectionInfoRaw): ReflectionInfo { const model: Record = {}; for (const route of info.routes) { - const basePath = info.routers.find((r) => r.router == route.router)?.route; - if (basePath == undefined) { - throw new Error('route has missing base path'); - } - const path = (basePath + route.pathSuffix).replace(/:([A-Za-z0-9_]+)/, "{$1}"); - const finalParams: Record = {}; - for (const param in route.params) { - const zodSchema = route.params[param]; - model[path + ' params ' + param] = zodSchema; - finalParams[param] = fixSchema(zodSchema.toJSONSchema(schemaOpts), true); - } - const finalQuery: Record = {}; - for (const key in route.query) { - const zodSchema = route.query[key]; - model[path + '?' + key] = zodSchema; - finalQuery[key] = fixSchema(zodSchema.toJSONSchema(schemaOpts), true); - } - const common = { - path, - params: finalParams, - query: finalQuery, - resBody: route.resBody === null ? null : fixSchema(route.resBody.toJSONSchema(schemaOpts), true), - summary: route.summary, - description: route.description, - }; - switch (route.method) { - case 'get': - resultRoutes.push({ ...common, method: 'get' }); - break; - case 'post': - resultRoutes.push({ - ...common, - method: 'post', - reqBody: route.reqBody === null - ? null - : fixSchema(route.reqBody.toJSONSchema(schemaOpts), true), - }); - if (route.reqBody) - model[path + ' reqBody'] = route.reqBody; - break; - default: - // TODO: use eslint exhaustiveness checking - const _: never = route; + try { + const basePath = info.routers.find((r) => r.router == route.router)?.route; + if (basePath == undefined) { + throw new Error('route has missing base path'); + } + const path = (basePath + route.pathSuffix).replace(/:([A-Za-z0-9_]+)/, "{$1}"); + const finalParams: Record = {}; + for (const param in route.params) { + const zodSchema = route.params[param]; + model[path + ' params ' + param] = zodSchema; + finalParams[param] = fixSchema(zodSchema.toJSONSchema(schemaOpts), true); + } + const finalQuery: Record = {}; + for (const key in route.query) { + const zodSchema = route.query[key]; + model[path + '?' + key] = zodSchema; + finalQuery[key] = fixSchema(zodSchema.toJSONSchema(schemaOpts), true); + } + const common = { + path, + params: finalParams, + query: finalQuery, + resBody: route.resBody === null ? null : fixSchema(route.resBody.toJSONSchema(schemaOpts), true), + summary: route.summary, + description: route.description, + }; + switch (route.method) { + case 'get': + resultRoutes.push({ ...common, method: 'get' }); + break; + case 'post': + resultRoutes.push({ + ...common, + method: 'post', + reqBody: route.reqBody === null + ? null + : fixSchema(route.reqBody.toJSONSchema(schemaOpts), true), + }); + if (route.reqBody) + model[path + ' reqBody'] = route.reqBody; + break; + default: + // TODO: use eslint exhaustiveness checking + const _: never = route; + } + if (route.resBody) + model[path + ' resBody'] = route.resBody; + } catch (e) { + throw new Error(`couldn't finalize "${route.pathSuffix}": ${e}`); } - if (route.resBody) - model[path + ' resBody'] = route.resBody; } return { routes: resultRoutes, From c7abc893fc50958d92a0fb95610cfb81d2185d5b Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Thu, 23 Jul 2026 22:26:52 -0700 Subject: [PATCH 12/19] feat(docu): make request errors less verbose --- src/routes/documented.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/routes/documented.ts b/src/routes/documented.ts index a77ad5e..72b4676 100644 --- a/src/routes/documented.ts +++ b/src/routes/documented.ts @@ -178,11 +178,14 @@ export function addGetRoute< const determineResponse = (req: ExpressRequest): { status: number, json: z.infer | { error: string } } => { let params = paramsSchema.safeParse(req.params); if (params.error) { - return { status: 400, json: { error: "invalid path params: " + params.error.message } }; + return { + status: 400, + json: formatError('invalid path params', params.error) + }; } let query = querySchema.safeParse(req.query); if (query.error) { - return { status: 400, json: { error: "invalid query params: " + query.error.message } }; + return { status: 400, json: formatError('invalid query params', query.error) }; } try { const result = handler(params.data, query.data); @@ -263,15 +266,15 @@ export function addPostRoute< const determineResponse = (req: ExpressRequest): { status: number, json: z.infer | { error: string } } => { let params = paramsSchema.safeParse(req.params); if (params.error) { - return { status: 400, json: { error: "invalid path params: " + params.error.message } }; + return { status: 400, json: formatError('invalid path params', params.error) }; } let query = querySchema.safeParse(req.query); if (query.error) { - return { status: 400, json: { error: "invalid query params: " + query.error.message } }; + return { status: 400, json: formatError('invalid query params', query.error) }; } let body = reqBodySchema.safeParse(req.body); if (body.error) { - return { status: 400, json: { error: "invalid body: " + body.error.message } }; + return { status: 400, json: formatError('invalid body', body.error) }; } try { const result = handler(params.data, query.data, body.data); @@ -292,6 +295,13 @@ export function addPostRoute< return determineResponse; } +function formatError(title: string, error: z.ZodError) { + const issuesText = error.issues + .map(i => `- ${i.path.length ? (i.path.join('.') + ': ') : ''}${i.message}`) + .join('\n'); + return { error: `${title}:\n${issuesText}` }; +} + // === end of interface for api defining === /** From 23675d756ebdcb735fcd244b9c281eea28a95df4 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Thu, 23 Jul 2026 22:38:22 -0700 Subject: [PATCH 13/19] fix(docu): refine type of query & path params Make sure the passed schemas can accept strings as input as that is what express gives as `path` and `query`. Update module docs. --- src/routes/documented.ts | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/routes/documented.ts b/src/routes/documented.ts index 72b4676..4107fd3 100644 --- a/src/routes/documented.ts +++ b/src/routes/documented.ts @@ -24,14 +24,11 @@ * * ### Be Careful With Zod Transformations * The OpenAPI spec will be populated with the output formats of the schemas you - * define routes with. This gives z.coerce the correct behavior and also will - * work with carefully constructed z.pipe chains, but a z.transform without - * further validation through pipe will not work. Uses of z.coerce, z.pipe, and - * z.transform should generally conform to a parse string into primative type - * pattern, and only be used for path and query parameters. Response schemas - * are not guaranteed to be used (currently they aren't but this may change in - * the future) so avoid *any* kind of data transformation in them unless it is - * confirmed that response schemas will be used. + * define routes with. This makes coerce work well with path/query formats but + * might be problematic with client generation if using transformations to + * non-primative types. Only use coerce, pipe, and transform with the parts of + * the request, not the response (the resBody schema is never used to validate, + * only to get a type). * * ## Getting OpenAPI Specs * @@ -106,13 +103,15 @@ export function makeFailureResponse(status: 400 | 401 | 403 | 404 | 500, erro * A type representing a Zod object (i.e. `z.object(...)`) where string fields * like those from query & path parms can get parsed. */ -export type StandardZodObject = z.ZodObject>>; +export type StringlyZodObject = z.ZodObject>>; /** * Meant to be used along with the spread operator to fill out format fields * that aren't cared about. */ -export const emptyFormat = { +export const emptyFormat: { + params: StringlyZodObject, query: StringlyZodObject, reqBody: z.ZodType, resBody: z.ZodType, +} = { params: z.object(), query: z.object(), reqBody: z.unknown(), resBody: z.unknown(), }; @@ -141,8 +140,8 @@ export const emptyFormat = { * to be used in testing */ export function addGetRoute< - P extends StandardZodObject, - Q extends StandardZodObject, + P extends StringlyZodObject, + Q extends StringlyZodObject, RB extends z.ZodType >( ctx: Context, @@ -228,8 +227,8 @@ export function addGetRoute< * to be used in testing */ export function addPostRoute< - P extends StandardZodObject, - Q extends StandardZodObject, + P extends StringlyZodObject, + Q extends StringlyZodObject, B extends z.ZodType, RB extends z.ZodType, >( From e2d48d445f5b487d39bce5575ee32533e3f1ff46 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Thu, 23 Jul 2026 22:42:00 -0700 Subject: [PATCH 14/19] test(docu): complete the test suite --- test/documented.test.ts | 272 +++++++++++++++++++++++++++++----------- 1 file changed, 199 insertions(+), 73 deletions(-) diff --git a/test/documented.test.ts b/test/documented.test.ts index 65fc75c..8534ebd 100644 --- a/test/documented.test.ts +++ b/test/documented.test.ts @@ -8,7 +8,7 @@ it('should handle path params (GET)', () => { testCase( 'get', '/root', '/path/{item}', - { ...d.emptyFormat, params: z.object({ item: z.string() }) }, + { ...d.emptyFormat, params: z.strictObject({ item: z.string() }) }, { params: { item: 'five' }, query: {}, body: {} }, { params: { wrong: 'five' }, query: {}, body: {} }, (correct) => expect(correct) @@ -22,16 +22,9 @@ it('should handle path params (GET)', () => { `), (incorrect) => expect(incorrect) .toMatchInlineSnapshot(` - "invalid path params: [ - { - "expected": "string", - "code": "invalid_type", - "path": [ - "item" - ], - "message": "Invalid input: expected string, received undefined" - } - ]" + "invalid path params: + - item: Invalid input: expected string, received undefined + - Unrecognized key: "wrong"" `), (spec) => expect(spec.paths['/root/path/{item}'].get?.parameters[0]) .toMatchInlineSnapshot(` @@ -50,21 +43,60 @@ it('should handle path params (GET)', () => { it('should handle query params (GET)', () => { testCase( 'get', '', '/api', - { query: z.object({ field: z.number() })}, + { query: z.object({ field: z.coerce.number() })}, { query: { field: "-2" } }, { query: { field: "no" } }, - (json) => expect(json).toMatchInlineSnapshot(), - (error) => expect(error).toMatchInlineSnapshot(), - (spec) => expect(spec.paths['/api'].get?.parameters[0]).toMatchInlineSnapshot() + (json) => expect(json).toMatchInlineSnapshot(` + { + "params": {}, + "query": { + "field": -2, + }, + } + `), + (error) => expect(error).toMatchInlineSnapshot(` + "invalid query params: + - field: Invalid input: expected number, received NaN" + `), + (spec) => expect(spec.paths['/api'].get?.parameters[0]).toMatchInlineSnapshot(` + { + "in": "query", + "name": "field", + "required": true, + "schema": { + "type": "number", + }, + } + `) ) }); it('should handle response bodies (GET)', () => { - // correct value goes through - // incorrect value is caught // shows up in docs + testCase( + 'get', '/4', '/34', + { resBody: z.number() }, + {}, {}, + (json) => expect(json).toMatchInlineSnapshot(` + { + "params": {}, + "query": {}, + } + `), + null, + (spec) => expect(spec.paths['/4/34'].get?.responses["2XX"].content?.["application/json"].schema) + .toMatchInlineSnapshot(` + { + "type": "number", + } + `) + ); // can be empty - expect(true).toBe(true); + testCase( + 'get', '', '/h', {}, {}, {}, null, null, + (spec) => expect(spec.paths['/h'].get!.responses["2XX"].content) + .toMatchInlineSnapshot(`undefined`) + ); }); it('should handle path params (POST)', () => { @@ -86,16 +118,8 @@ it('should handle path params (POST)', () => { `), (incorrect) => expect(incorrect) .toMatchInlineSnapshot(` - "invalid path params: [ - { - "expected": "string", - "code": "invalid_type", - "path": [ - "item" - ], - "message": "Invalid input: expected string, received undefined" - } - ]" + "invalid path params: + - item: Invalid input: expected string, received undefined" `), (spec) => expect(spec.paths['/root/path/{item}'].post?.parameters[0]) .toMatchInlineSnapshot(` @@ -114,29 +138,160 @@ it('should handle path params (POST)', () => { it('should handle query params (POST)', () => { testCase( 'post', '', '/api', - { query: z.object({ field: z.number() })}, + { query: z.object({ field: z.coerce.number() })}, { query: { field: "-2" } }, { query: { field: "no" } }, - (json) => expect(json).toMatchInlineSnapshot(), - (error) => expect(error).toMatchInlineSnapshot(), - (spec) => expect(spec.paths['/api'].post?.parameters[0]).toMatchInlineSnapshot() + (json) => expect(json).toMatchInlineSnapshot(` + { + "body": {}, + "params": {}, + "query": { + "field": -2, + }, + } + `), + (error) => expect(error).toMatchInlineSnapshot(` + "invalid query params: + - field: Invalid input: expected number, received NaN" + `), + (spec) => expect(spec.paths['/api'].post?.parameters[0]).toMatchInlineSnapshot(` + { + "in": "query", + "name": "field", + "required": true, + "schema": { + "type": "number", + }, + } + `) ) }); it('should handle request bodies (POST)', () => { - // correct value goes through - // incorrect value is caught - // shows up in docs - // can be empty - expect(true).toBe(true); + testCase( + 'post', '/a/b', '/c', + { reqBody: z.object({ field: z.boolean() })}, + { body: { field: true } }, + { body: { field: [] } }, + (json) => expect(json).toMatchInlineSnapshot(` + { + "body": { + "field": true, + }, + "params": {}, + "query": {}, + } + `), + (error) => expect(error).toMatchInlineSnapshot(` + "invalid body: + - field: Invalid input: expected boolean, received array" + `), + (spec) => expect(spec.paths['/a/b/c'].post?.parameters[0]).toMatchInlineSnapshot(`undefined`) + ) }); it('should handle response bodies (POST)', () => { - // correct value goes through - // incorrect value is caught // shows up in docs + testCase( + 'post', '/4', '/34', + { resBody: z.number() }, + {}, {}, + (json) => expect(json).toMatchInlineSnapshot(` + { + "body": {}, + "params": {}, + "query": {}, + } + `), + null, + (spec) => expect(spec.paths['/4/34'].post?.responses["2XX"].content?.["application/json"].schema) + .toMatchInlineSnapshot(` + { + "type": "number", + } + `) + ); // can be empty - expect(true).toBe(true); + testCase( + 'post', '', '/h', {}, {}, {}, null, null, + (spec) => expect(spec.paths['/h'].post!.responses["2XX"].content) + .toMatchInlineSnapshot(`undefined`) + ); +}); + + +it('should be able to handle GET & POST to the same path', () => { + const app = express(); + const router = express.Router(); + + const ctx = d.newContext(); + d.addRouter(ctx, app, '', router); + + d.addGetRoute(ctx, router, '/rt', d.emptyFormat, () => d.makeSuccessResponse(200, {}), {}); + d.addPostRoute(ctx, router, '/rt', d.emptyFormat, () => d.makeSuccessResponse(200, {}), {}); + + const path = d.docsFor(ctx).paths['/rt']; + expect(path.get).toBeDefined(); + expect(path.post).toBeDefined(); + expect(path.get).toEqual(path.post); +}); + +it('should handle zod transform types in the request', () => { + const app = express(); + const router = express.Router(); + + const ctx = d.newContext(); + d.addRouter(ctx, app, '', router); + + const handler = d.addPostRoute( + ctx, router, '/rt', + { ...d.emptyFormat, reqBody: z.string().transform((s) => s.toLowerCase()) }, + (_, __, body) => d.makeSuccessResponse(200, body) + ); + const res = handler({ params: {}, query: {}, body: 'HI' }); + expect(res.json).toMatchInlineSnapshot(`"hi"`); +}); + +it('should surface type descriptions & names', () => { + const app = express(); + const router = express.Router(); + + const ctx = d.newContext(); + d.addRouter(ctx, app, '', router); + d.addGetRoute( + ctx, router, '/pan', + { ...d.emptyFormat, resBody: z.object().meta({ id: 'Obj', description: 'obj' }) }, + () => d.makeSuccessResponse(200, {}) + ); + const spec = d.docsFor(ctx); + expect(spec.components.schemas).toMatchInlineSnapshot(` + { + "Obj": { + "additionalProperties": false, + "description": "obj", + "properties": {}, + "type": "object", + }, + } + `); +}); + +it('should surface route descriptions & names', () => { + const app = express(); + const router = express.Router(); + + const ctx = d.newContext(); + d.addRouter(ctx, app, '', router); + d.addGetRoute( + ctx, router, '/pan', + d.emptyFormat, + () => d.makeSuccessResponse(200, {}), + { summary: 'summary', description: 'description' } + ); + const spec = d.docsFor(ctx); + const path = spec.paths['/pan'].get; + expect(path).toHaveProperty('description', 'description'); + expect(path).toHaveProperty('summary', 'summary'); }); function testCase( @@ -146,8 +301,8 @@ function testCase( format: Partial, correct: Partial, incorrect: Partial, - validateCorrect: (json: unknown) => unknown, - validateIncorrect: (error: string) => unknown, + validateCorrect: null | ((json: unknown) => unknown), + validateIncorrect: null | ((error: string) => unknown), validateSpec: (spec: d.OpenAPI) => unknown, ) { const app = express(); @@ -169,14 +324,14 @@ function testCase( const defaultResponse: d.ExpressRequest = { query: {}, params: {}, body: {} }; // correct value goes through? - { + if (validateCorrect) { const res = handler({ ...defaultResponse, ...correct }); expect(res.status).toBe(200); validateCorrect(res.json); } // incorrect value is caught? - { + if (validateIncorrect) { const res = handler({ ...defaultResponse, ...incorrect }); expect(res.status).toBe(400); validateIncorrect( @@ -190,32 +345,3 @@ function testCase( const spec = d.docsFor(ctx); validateSpec(spec); } - -it('should be able to handle GET & POST to the same path', () => { - expect(true).toBe(true); -}); - -it('should handle zod coerce types', () => { - // type is correct - // docs don't error - expect(true).toBe(true); -}); - -it('should handle zod pipe/transform types', () => { - // type is correct - // docs don't error - expect(true).toBe(true); -}); - -it('should surface type descriptions & names', () => { - expect(true).toBe(true); -}); - -it('should surface route descriptions & names', () => { - expect(true).toBe(true); -}); - -it('should have a stable output', () => { - expect(true).toBe(true); -}); - From 870817dda9480eac060c348ea78e5bd08d215ad7 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Thu, 23 Jul 2026 23:14:33 -0700 Subject: [PATCH 15/19] fix(api): remove z.transform from /reminders/:token --- src/routes/api.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/routes/api.ts b/src/routes/api.ts index bb953c5..187ed83 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -556,7 +556,7 @@ export function swapToken(req: express.Request, res: express.Response) { } router.post('/swapToken', swapToken); -const Token = z.string().transform(reminderService.registrationToken).meta({ id: "Token" }) +const Token = z.string().meta({ id: "Token" }) const ActiveReminder = z.object({ stpid: z.string(), rtid: z.string(), @@ -572,6 +572,7 @@ documented.addGetRoute( resBody: z.object({ reminders: z.array(ActiveReminder) }), }, ({ token }, _) => { + const regTok = reminderService.registrationToken(token); const subscriptionInfo = (r: reminderService.PreThreshold | reminderService.PostThreshold) => { return { stpid: r.event.stpid, @@ -583,11 +584,11 @@ documented.addGetRoute( console.log(`Got request for active reminders of ${token}`); const universityReminders = reminderService .universityReminderSubscriptions - .activeRemindersFor(token) + .activeRemindersFor(regTok) .map(subscriptionInfo); const rideReminders = reminderService .rideReminderSubscriptions - .activeRemindersFor(token) + .activeRemindersFor(regTok) .map(subscriptionInfo); return documented.makeSuccessResponse(200, { reminders: universityReminders.concat(rideReminders) }); }, From 2368ed50201280ca812e7bd2581b3dd2e47426a1 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Thu, 23 Jul 2026 23:56:43 -0700 Subject: [PATCH 16/19] test: check if documented is enabled first --- .github/workflows/ci.yml | 1 + src/routes/documented.ts | 2 ++ test/documented.test.ts | 8 +++++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1eb31a7..a863850 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ jobs: working-directory: ${{ github.workspace }} - name: Test Suite run: | + export DOCUMENTED=1 export MBUS_URL=https://mbus.bustime.mock.mb.thething.fyi/ export RIDE_URL=https://ride.bustime.mock.mb.thething.fyi/ npm start & diff --git a/src/routes/documented.ts b/src/routes/documented.ts index 4107fd3..7ce176e 100644 --- a/src/routes/documented.ts +++ b/src/routes/documented.ts @@ -559,6 +559,7 @@ function makeOpenAPI(info: ReflectionInfo): OpenAPI { /** Get the OpenAPI spec as a structured object. */ export function docsFor(ctx: Context) { + if (!ENABLED) throw new Error('documented must be enabled'); const finalized = finalize(ctx); const openAPI = makeOpenAPI(finalized); return openAPI; @@ -569,6 +570,7 @@ export function docsFor(ctx: Context) { * console if this isn't set. Will also exit the process if configured to do so. */ export async function outputDocsFor(ctx: Context) { + if (!ENABLED) throw new Error('documented must be enabled'); console.log('outputting docs...'); const openAPI = docsFor(ctx); const output = JSON.stringify(openAPI, null, 4); diff --git a/test/documented.test.ts b/test/documented.test.ts index 8534ebd..f84151c 100644 --- a/test/documented.test.ts +++ b/test/documented.test.ts @@ -1,9 +1,15 @@ -import { expect, it } from "vitest"; +import { beforeAll, expect, it } from "vitest"; import * as d from '@/routes/documented'; import express from 'express'; import z from 'zod'; +beforeAll(() => { + if (!d.ENABLED) { + throw new Error('documented must be enabled'); + } +}); + it('should handle path params (GET)', () => { testCase( 'get', From d0f3d987f59b001beaa023e9f0fadc08fa64e0dd Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sat, 25 Jul 2026 07:00:40 -0700 Subject: [PATCH 17/19] fix(api): restrict success status codes The previous approach of supporting many status codes and having a 2XX entry in the generated OpenAPI spec doesn't work well with swagger_parser. Switch to using only the 200 status code and having an entry for 200 instead. --- src/routes/api.ts | 4 ++-- src/routes/documented.ts | 20 +++++++++++++------- test/documented.test.ts | 22 +++++++++++----------- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/routes/api.ts b/src/routes/api.ts index 187ed83..1bd8f8d 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -503,7 +503,7 @@ documented.addPostRoute( predsByStopId, Date.now(), ); - return documented.makeSuccessResponse(200, {}); + return documented.makeSuccessResponse({}); } ); @@ -590,7 +590,7 @@ documented.addGetRoute( .rideReminderSubscriptions .activeRemindersFor(regTok) .map(subscriptionInfo); - return documented.makeSuccessResponse(200, { reminders: universityReminders.concat(rideReminders) }); + return documented.makeSuccessResponse({ reminders: universityReminders.concat(rideReminders) }); }, { summary: "active reminders", diff --git a/src/routes/documented.ts b/src/routes/documented.ts index 7ce176e..0a5f906 100644 --- a/src/routes/documented.ts +++ b/src/routes/documented.ts @@ -22,6 +22,12 @@ * take a look at {@link HandlerReturn} + remember the existence of * {@link emptyFormat} and {@link globalContext}. * + * If a Zod schema is reused / important enough to get its own variable, make + * sure to at the very least add `.meta({ id: 'unique name' })` to it so that + * API/docs consumers can also take advantage of this. Other schemas can also + * have this even if they are not variables. Note that id must be unique, and + * accidentally setting `name` instead won't have the intended outcome. + * * ### Be Careful With Zod Transformations * The OpenAPI spec will be populated with the output formats of the schemas you * define routes with. This makes coerce work well with path/query formats but @@ -76,11 +82,11 @@ export function addRouter(ctx: Context, app: express.Express, route: string, rou * You should genrally use either {@link makeSuccessResponse} or * {@link makeFailureResponse} to construct this. * - * Feel free to add more codes here and to the make[A-Za-z]*Response functions - * as you need them. + * Success responses were limited to just 200 because the response body of a + * 2XX catch-all entry isn't reflected in swagger_parser's output. */ export type HandlerReturn = { - success: true, status: 200 | 201 | 202 | 203 | 205, json: T + success: true, status: 200, json: T } | { success: false, status: 400 | 401 | 403 | 404 | 500, error: string }; @@ -88,8 +94,8 @@ export type HandlerReturn = { /** * helper function that should avoid weird typechecker issues */ -export function makeSuccessResponse(status: 200 | 201 | 202 | 203 | 205, json: T): HandlerReturn { - return { success: true, status, json }; +export function makeSuccessResponse(json: T): HandlerReturn { + return { success: true, status: 200, json }; } /** @@ -358,7 +364,7 @@ interface OpenAPIPathCommon { required: boolean, }>, responses: { - "2XX": { + "200": { description: "success", content?: { "application/json": { @@ -513,7 +519,7 @@ function makeOpenAPI(info: ReflectionInfo): OpenAPI { ? undefined : { 'application/json': { schema: route.resBody } }; const responses: OpenAPIGetPath['responses'] = { - '2XX': { + '200': { description: 'success', content, } diff --git a/test/documented.test.ts b/test/documented.test.ts index f84151c..0e23b21 100644 --- a/test/documented.test.ts +++ b/test/documented.test.ts @@ -90,7 +90,7 @@ it('should handle response bodies (GET)', () => { } `), null, - (spec) => expect(spec.paths['/4/34'].get?.responses["2XX"].content?.["application/json"].schema) + (spec) => expect(spec.paths['/4/34'].get?.responses["200"].content?.["application/json"].schema) .toMatchInlineSnapshot(` { "type": "number", @@ -100,7 +100,7 @@ it('should handle response bodies (GET)', () => { // can be empty testCase( 'get', '', '/h', {}, {}, {}, null, null, - (spec) => expect(spec.paths['/h'].get!.responses["2XX"].content) + (spec) => expect(spec.paths['/h'].get!.responses["200"].content) .toMatchInlineSnapshot(`undefined`) ); }); @@ -210,7 +210,7 @@ it('should handle response bodies (POST)', () => { } `), null, - (spec) => expect(spec.paths['/4/34'].post?.responses["2XX"].content?.["application/json"].schema) + (spec) => expect(spec.paths['/4/34'].post?.responses["200"].content?.["application/json"].schema) .toMatchInlineSnapshot(` { "type": "number", @@ -220,7 +220,7 @@ it('should handle response bodies (POST)', () => { // can be empty testCase( 'post', '', '/h', {}, {}, {}, null, null, - (spec) => expect(spec.paths['/h'].post!.responses["2XX"].content) + (spec) => expect(spec.paths['/h'].post!.responses["200"].content) .toMatchInlineSnapshot(`undefined`) ); }); @@ -233,8 +233,8 @@ it('should be able to handle GET & POST to the same path', () => { const ctx = d.newContext(); d.addRouter(ctx, app, '', router); - d.addGetRoute(ctx, router, '/rt', d.emptyFormat, () => d.makeSuccessResponse(200, {}), {}); - d.addPostRoute(ctx, router, '/rt', d.emptyFormat, () => d.makeSuccessResponse(200, {}), {}); + d.addGetRoute(ctx, router, '/rt', d.emptyFormat, () => d.makeSuccessResponse({}), {}); + d.addPostRoute(ctx, router, '/rt', d.emptyFormat, () => d.makeSuccessResponse({}), {}); const path = d.docsFor(ctx).paths['/rt']; expect(path.get).toBeDefined(); @@ -252,7 +252,7 @@ it('should handle zod transform types in the request', () => { const handler = d.addPostRoute( ctx, router, '/rt', { ...d.emptyFormat, reqBody: z.string().transform((s) => s.toLowerCase()) }, - (_, __, body) => d.makeSuccessResponse(200, body) + (_, __, body) => d.makeSuccessResponse(body) ); const res = handler({ params: {}, query: {}, body: 'HI' }); expect(res.json).toMatchInlineSnapshot(`"hi"`); @@ -267,7 +267,7 @@ it('should surface type descriptions & names', () => { d.addGetRoute( ctx, router, '/pan', { ...d.emptyFormat, resBody: z.object().meta({ id: 'Obj', description: 'obj' }) }, - () => d.makeSuccessResponse(200, {}) + () => d.makeSuccessResponse({}) ); const spec = d.docsFor(ctx); expect(spec.components.schemas).toMatchInlineSnapshot(` @@ -291,7 +291,7 @@ it('should surface route descriptions & names', () => { d.addGetRoute( ctx, router, '/pan', d.emptyFormat, - () => d.makeSuccessResponse(200, {}), + () => d.makeSuccessResponse({}), { summary: 'summary', description: 'description' } ); const spec = d.docsFor(ctx); @@ -320,12 +320,12 @@ function testCase( ? d.addGetRoute( ctx, router, suffix, { ...d.emptyFormat, ...format }, - (params, query) => d.makeSuccessResponse(200, { params, query }) + (params, query) => d.makeSuccessResponse({ params, query }) ) : d.addPostRoute( ctx, router, suffix, { ...d.emptyFormat, ...format }, - (params, query, body) => d.makeSuccessResponse(200, { params, query, body }) + (params, query, body) => d.makeSuccessResponse({ params, query, body }) ); const defaultResponse: d.ExpressRequest = { query: {}, params: {}, body: {} }; From 4c9f166ff1cc3b15022de07fab87c1d8fd9d862d Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sat, 25 Jul 2026 10:12:27 -0700 Subject: [PATCH 18/19] docs(docu): add example, add note about tuples --- src/routes/documented.ts | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/routes/documented.ts b/src/routes/documented.ts index 0a5f906..2290273 100644 --- a/src/routes/documented.ts +++ b/src/routes/documented.ts @@ -15,13 +15,49 @@ * * # Getting Started * - * ## Defining Routes + * ```typescript + * // have express app + * const app = express(); + * + * // cool router (mandatory probably) + * const router = express.Router(); + * + * // use local context if you want + * const ctx = newContext(); + * + * // add router to app (app.use(router, '/api')) + * addRouter(globalContext, app, router, '/api') + * + * // add route (/api/double) + * addGetRoute( + * globalContext, router, '/double', + * // Zod schemas for each part of the request & response, defaults=emptyFormat + * // since query params end up as strings, z.coerce.number() is needed not z.number() + * { ...emptyFormat, query: z.object({ x: z.coerce.number() }), resBody: z.number() }, + * // handling logic goes here, first arg is ignored b/c it is the path params + * (_, { x }) => { + * // x is already a number as opposed to any/unknown + * makeSuccessResponse(x * 2); + * }, + * // documentation goes here + * { name: 'double a number', description: 'f: R -> R, x |-> 2x'} + * ); + * + * // get docs + * const spec: OpenAPI = docsFor(globalContext); + * // output docs + * outputDocsFor(globalContext); + * ``` * + * ## Defining Routes + * * Make sure you know how to use Zod, then look into {@link addRouter}, * {@link addGetRoute}, and {@link addPostRoute}. It would also be useful to * take a look at {@link HandlerReturn} + remember the existence of * {@link emptyFormat} and {@link globalContext}. * + * `z.tuple` isn't handled well by swagger_parser, prefer objects instead. + * * If a Zod schema is reused / important enough to get its own variable, make * sure to at the very least add `.meta({ id: 'unique name' })` to it so that * API/docs consumers can also take advantage of this. Other schemas can also @@ -43,7 +79,6 @@ * `DOCUMENTED_EXIT_ON_OUTPUT`. Also look at {@link globalContext}, * {@link docsFor}, and {@link outputDocsFor}. * - * TODO: add examples * @module */ From 0c43d8b576a6bc3e25a2ae32a3dbdda06e78e063 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sat, 25 Jul 2026 15:34:06 -0700 Subject: [PATCH 19/19] fix(docu): support schemas with "id" field --- src/routes/documented.ts | 1 - test/documented.test.ts | 23 ++++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/routes/documented.ts b/src/routes/documented.ts index 2290273..19d7fdb 100644 --- a/src/routes/documented.ts +++ b/src/routes/documented.ts @@ -464,7 +464,6 @@ function finalize(info: ReflectionInfoRaw): ReflectionInfo { delete s['$defs']; } if ('$schema' in s) delete s['$schema']; - if ('id' in s) delete s['id']; for (const v of Object.values(s)) { stripExtraKeys(v, shouldStripDefs); } diff --git a/test/documented.test.ts b/test/documented.test.ts index 0e23b21..51a9641 100644 --- a/test/documented.test.ts +++ b/test/documented.test.ts @@ -14,7 +14,7 @@ it('should handle path params (GET)', () => { testCase( 'get', '/root', '/path/{item}', - { ...d.emptyFormat, params: z.strictObject({ item: z.string() }) }, + { params: z.strictObject({ item: z.string() }) }, { params: { item: 'five' }, query: {}, body: {} }, { params: { wrong: 'five' }, query: {}, body: {} }, (correct) => expect(correct) @@ -46,6 +46,27 @@ it('should handle path params (GET)', () => { ); }); +it('should accept params named id', () => { + testCase( + 'get', + '', '/test', + { params: z.object({ id: z.string() }) }, + {}, {}, null, null, + (spec) => expect(spec.paths['/test'].get?.parameters[0].name).toEqual("id") + ); +}); + +it('should work with schemas containing id', () => { + const B = z.object({ id: z.number() }).meta({ id: 'B'}); + testCase( + 'post', + '', '/test', + { reqBody: B, resBody: B }, + {}, {}, null, null, + (spec) => expect(spec.components.schemas.B.properties).toHaveProperty('id'), + ); +}); + it('should handle query params (GET)', () => { testCase( 'get', '', '/api',