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..bdccfe4 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, 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.object(), + 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..cfa3ed7 --- /dev/null +++ b/src/routes/helper.ts @@ -0,0 +1,297 @@ +/** + * 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: post support + * TODO: add examples + * TODO: add tests + */ + +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: Record, + query: Record, + resBody: z.ZodType, + summary: string, + description: string, + }>, +}; + +interface ReflectionInfo { + routes: Array<{ + path: string, + method: 'get', + params: Record, + query: Record, + resBody: JSONSchema.BaseSchema, + summary: string, + description: string, + }>, + 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', + 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).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: finalParams, + query: finalQuery, + resBody: fixSchema(route.resBody.toJSONSchema(schemaOpts)), + summary: route.summary, + description: route.description, + }); + model[path + ' resBody'] = route.resBody; + } + return { + routes: resultRoutes, + 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); + const openAPI = makeOpenAPI(finalized); + 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 }; +} + +/** + * 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.ZodObject>, + Q extends z.ZodObject>, + 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 */ + 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 + * TODO: make this + */