From 284e61bc3429e408d7439411f0f439234a32fdb1 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Fri, 3 Jul 2026 16:17:21 -0700 Subject: [PATCH 1/2] 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 96fbbff3ce1b978e97ea6ebcf440d7c58b3409c0 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Fri, 3 Jul 2026 23:00:32 -0700 Subject: [PATCH 2/2] 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 ?? "", }) }