diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64f2ccf..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 & @@ -59,7 +60,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 +69,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/.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/app.ts b/src/app.ts index 7690451..c5508a6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,11 +1,12 @@ import express from "express"; import mbus from "./routes/api" +import * as documented from "./routes/documented"; const app = express(); app.use(express.json()); -app.use("/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; @@ -13,4 +14,7 @@ const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); + 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 cc03750..1bd8f8d 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 * as documented from "./documented"; /** * 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", }); @@ -487,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; +documented.addPostRoute( + documented.globalContext, router, '/setReminder', { ...documented.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 documented.makeFailureResponse(400, `Invalid route ${rtid}`); } const { reminderSubscriptions, predsByStopId } = info; reminderSubscriptions.add( @@ -512,11 +503,9 @@ export function setReminder(req: express.Request, res: express.Response) { predsByStopId, Date.now(), ); - res.sendStatus(200); + return documented.makeSuccessResponse({}); } - -} -router.post('/setReminder', setReminder); +); const UnsetReminderBody = z.object({ token: z.string(), stpid: z.string(), rtid: z.string() }); /** @@ -529,7 +518,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 +556,47 @@ 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().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 => +documented.addGetRoute( + documented.globalContext, 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 regTok = reminderService.registrationToken(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(regTok) + .map(subscriptionInfo); + const rideReminders = reminderService + .rideReminderSubscriptions + .activeRemindersFor(regTok) + .map(subscriptionInfo); + return documented.makeSuccessResponse({ 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 +634,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 +659,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/documented.ts b/src/routes/documented.ts new file mode 100644 index 0000000..19d7fdb --- /dev/null +++ b/src/routes/documented.ts @@ -0,0 +1,624 @@ +/** + * Wrappers around stuff you would otherwise do with express but with reflection + * 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). + * + * Extra functionality can be added as needed. + * + * # Getting Started + * + * ```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 + * 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 + * 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 + * + * 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}. + * + * @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'; +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 === + +/** + * 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) { + ctx.routers.push({ route, router }); + } + app.use(route, router); +} + +/** + * You should genrally use either {@link makeSuccessResponse} or + * {@link makeFailureResponse} to construct this. + * + * 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, json: T +} | { + success: false, status: 400 | 401 | 403 | 404 | 500, error: string +}; + +/** + * helper function that should avoid weird typechecker issues + */ +export function makeSuccessResponse(json: T): HandlerReturn { + return { success: true, status: 200, 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 }; +} + +/** + * A type representing a Zod object (i.e. `z.object(...)`) where string fields + * like those from query & path parms can get parsed. + */ +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: { + params: StringlyZodObject, query: StringlyZodObject, reqBody: z.ZodType, resBody: z.ZodType, +} = { + 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 (e.g. using headers) just use the + * router directly for now, the functionality needed could be incorporated in + * the future. + * + * @typeParam P - path parameters as a zod object + * @typeParam Q - query parameters as a zod object + * @typeParam RB - response body as a zod type + * + * @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 + * + * @returns the handler function that got passed to `router.get` internally, + * to be used in testing + */ +export function addGetRoute< + P extends StringlyZodObject, + Q extends StringlyZodObject, + RB extends z.ZodType +>( + ctx: Context, + 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 (ENABLED) { + ctx.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: ExpressRequest, res: express.Response | { error: string }>) => { + const { status, json } = determineResponse(req); + res.status(status).json(json); + }); + + const determineResponse = (req: ExpressRequest): { status: number, json: z.infer | { error: string } } => { + let params = paramsSchema.safeParse(req.params); + if (params.error) { + return { + status: 400, + json: formatError('invalid path params', params.error) + }; + } + let query = querySchema.safeParse(req.query); + if (query.error) { + return { status: 400, json: formatError('invalid query params', query.error) }; + } + 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) } } + } + } + } + return determineResponse; +} + +/** + * Wrapper around router.post with built in validation and schema recording, + * more details can be found in {@link addGetRoute}. + * + * @typeParam P - path params + * @typeParam Q - query params + * @typeParam B - request body + * @typeParam 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 + * + * @returns the handler function that got passed to `router.post` internally, + * to be used in testing + */ +export function addPostRoute< + P extends StringlyZodObject, + Q extends StringlyZodObject, + B extends z.ZodType, + RB extends z.ZodType, +>( + ctx: Context, + 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 (ENABLED) { + ctx.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: ExpressRequest): { status: number, json: z.infer | { error: string } } => { + let params = paramsSchema.safeParse(req.params); + if (params.error) { + return { status: 400, json: formatError('invalid path params', params.error) }; + } + let query = querySchema.safeParse(req.query); + if (query.error) { + return { status: 400, json: formatError('invalid query params', query.error) }; + } + let body = reqBodySchema.safeParse(req.body); + if (body.error) { + return { status: 400, json: formatError('invalid body', body.error) }; + } + 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) } } + } + } + } + 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 === + +/** + * The context you should probably be using for everything unless writing a + * test. + */ +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; + +/** + * @internal + */ +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<{ + router: express.Router, + pathSuffix: string, + summary: string, + description: string, + params: Record, + query: Record, + resBody: z.ZodType | null, + } & ({ method: 'get' } | { method: 'post', reqBody: z.ZodType | null })>, +}; + +interface ReflectionInfo { + routes: Array<{ + path: string, + summary: string, + description: string, + params: Record, + query: Record, + resBody: JSONSchema.BaseSchema | null, + } & ({ method: 'get' } | { method: 'post', reqBody: JSONSchema.BaseSchema | null })>, + defs: Record, +}; + +interface OpenAPIPathCommon { + summary: string, + description: string, + parameters: Array<{ + name: string, + in: "path" | "query", + schema: JSONSchema.JSONSchema, + required: boolean, + }>, + responses: { + "200": { + description: "success", + content?: { + "application/json": { + schema: JSONSchema.JSONSchema, + } + } + } + }, +}; + +export interface OpenAPIGetPath extends OpenAPIPathCommon { }; + +export 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 */ +export interface OpenAPI { + openapi: "3.1.2", + info: { + title: string, + version: string, + }, + components: { + schemas: Record, + }, + 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 => { + 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, shouldStripDefs); + } + return s; + }; + + const stripExtraKeys = (s: T, shouldStripDefs: boolean): T => { + if (typeof s !== 'object' || !s) return s; + if (shouldStripDefs && '$defs' in s) { + delete s['$defs']; + } + if ('$schema' in s) delete s['$schema']; + for (const v of Object.values(s)) { + stripExtraKeys(v, shouldStripDefs); + } + return s; + } + + const schemaOpts: ToJSONSchemaParams = { + // reused: 'ref', + io: 'output', + } + const resultRoutes: ReflectionInfo['routes'] = []; + + // used to get the shared $defs + const model: Record = {}; + + for (const route of info.routes) { + 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}`); + } + } + return { + routes: resultRoutes, + defs: fixSchema(z.object(model).toJSONSchema(schemaOpts), false).$defs ?? {}, + }; +} + +function makeOpenAPI(info: ReflectionInfo): OpenAPI { + 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 content = route.resBody === null + ? undefined + : { 'application/json': { schema: route.resBody } }; + const responses: OpenAPIGetPath['responses'] = { + '200': { + description: 'success', + content, + } + }; + 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': + const requestBody = route.reqBody == null + ? undefined + : { content: { "application/json": { schema: route.reqBody } }, required: true }; + return { + url: route.path, method: 'post', path: { + requestBody, ...common + } + }; + } + }); + const paths: OpenAPI['paths'] = {}; + 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", + info: { + title: "Maize Bus Backend", + version: "", + }, + components: { schemas: info.defs }, + paths, + } +} + +/** 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; +} + +/** + * Output the OpenAPI spec to the file specified by the environment, or to the + * 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); + if (OUTPUT_FILE) + await fs.writeFile(OUTPUT_FILE, output); + else + console.log(output); + if (EXIT_ON_OUTPUT) + exit(0); +} + diff --git a/test/documented.test.ts b/test/documented.test.ts new file mode 100644 index 0000000..51a9641 --- /dev/null +++ b/test/documented.test.ts @@ -0,0 +1,374 @@ +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', + '/root', '/path/{item}', + { params: z.strictObject({ 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: + - item: Invalid input: expected string, received undefined + - Unrecognized key: "wrong"" + `), + (spec) => expect(spec.paths['/root/path/{item}'].get?.parameters[0]) + .toMatchInlineSnapshot(` + { + "in": "path", + "name": "item", + "required": true, + "schema": { + "type": "string", + }, + } + `), + ); +}); + +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', + { query: z.object({ field: z.coerce.number() })}, + { query: { field: "-2" } }, + { query: { field: "no" } }, + (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)', () => { + // 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["200"].content?.["application/json"].schema) + .toMatchInlineSnapshot(` + { + "type": "number", + } + `) + ); + // can be empty + testCase( + 'get', '', '/h', {}, {}, {}, null, null, + (spec) => expect(spec.paths['/h'].get!.responses["200"].content) + .toMatchInlineSnapshot(`undefined`) + ); +}); + +it('should handle path params (POST)', () => { + 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: + - item: 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)', () => { + testCase( + 'post', '', '/api', + { query: z.object({ field: z.coerce.number() })}, + { query: { field: "-2" } }, + { query: { field: "no" } }, + (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)', () => { + 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)', () => { + // 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["200"].content?.["application/json"].schema) + .toMatchInlineSnapshot(` + { + "type": "number", + } + `) + ); + // can be empty + testCase( + 'post', '', '/h', {}, {}, {}, null, null, + (spec) => expect(spec.paths['/h'].post!.responses["200"].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({}), {}); + d.addPostRoute(ctx, router, '/rt', d.emptyFormat, () => d.makeSuccessResponse({}), {}); + + 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(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({}) + ); + 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({}), + { 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( + mode: 'get' | 'post', + base: string, + suffix: string, + format: Partial, + correct: Partial, + incorrect: Partial, + validateCorrect: null | ((json: unknown) => unknown), + validateIncorrect: null | ((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({ params, query }) + ) + : d.addPostRoute( + ctx, router, suffix, + { ...d.emptyFormat, ...format }, + (params, query, body) => d.makeSuccessResponse({ params, query, body }) + ); + + 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( + 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); +} 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/*"