>>;
+
+/**
+ * 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/src/services/mbus.ts b/src/services/mbus.ts
index 1c9eeeb..a479f5a 100644
--- a/src/services/mbus.ts
+++ b/src/services/mbus.ts
@@ -5,7 +5,7 @@ import dotenv from "dotenv";
dotenv.config();
const API_KEY = process.env.MBUS_API_KEY;
-const BASE_URL = 'https://mbus.ltp.umich.edu/bustime/api/v3/';
+const BASE_URL = process.env.MBUS_URL || 'https://mbus.ltp.umich.edu/bustime/api/v3/';
const client = axios.create({
baseURL: BASE_URL,
diff --git a/src/services/reminder.ts b/src/services/reminder.ts
index a274244..8b87c78 100644
--- a/src/services/reminder.ts
+++ b/src/services/reminder.ts
@@ -61,7 +61,7 @@ function prdctdnToNum(prdctdn: string): number {
}
/** result of ReminderSubscriptons.process */
-type RemindersToTrigger = {
+export type RemindersToTrigger = {
/** can be sent in bulk based off of route, stop, and threshold (or route, stop, and prdctdn) */
reminder: Map, Set>,
/** can be sent in bulk based off of what route and stop */
@@ -74,7 +74,7 @@ type RemindersToTrigger = {
};
/** Subscriptions go through a pipeline, see types above for details. */
-class ReminderSubscriptions {
+export class ReminderSubscriptions {
subscriptions: Array<{
token: RegistrationToken, subscription: PreThreshold | PostThreshold
}>
diff --git a/src/services/reminderTypes.ts b/src/services/reminderTypes.ts
index eae8a53..5f4f54c 100644
--- a/src/services/reminderTypes.ts
+++ b/src/services/reminderTypes.ts
@@ -13,8 +13,8 @@ export function fromKey(key: Key): T {
return JSON.parse(key);
}
-/** internal */
-type CoreEvent = {
+/** @internal */
+export type CoreEvent = {
stpid: string,
rtid: string,
}
diff --git a/src/services/ride.ts b/src/services/ride.ts
index 2a6455f..bd8fd31 100644
--- a/src/services/ride.ts
+++ b/src/services/ride.ts
@@ -7,7 +7,7 @@ import dotenv from "dotenv";
dotenv.config();
const RIDE_API_KEY = process.env.RIDE_API_KEY;
-const BASE_URL = 'https://rt.theride.org/bustime/api/v3/';
+const BASE_URL = process.env.RIDE_URL || 'https://rt.theride.org/bustime/api/v3/';
const client = axios.create({
baseURL: BASE_URL,
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/test/reminder.test.ts b/test/reminder.test.ts
index 5a2a8f8..0d991e8 100644
--- a/test/reminder.test.ts
+++ b/test/reminder.test.ts
@@ -203,14 +203,14 @@ describe('Reminders', () => {
it('should get unix timestamp from ride bus api', async () => {
configDotenv();
const RIDE_API_KEY = process.env.RIDE_API_KEY;
- const BASE_URL = 'https://rt.theride.org/bustime/api/v3/';
+ const BASE_URL = process.env.RIDE_URL || 'https://rt.theride.org/bustime/api/v3/';
const client = axios.create({
baseURL: BASE_URL,
params: { key: RIDE_API_KEY, format: 'json' }
});
const res = await client.get('/gettime', { params: { unixTime: true } });
- expect(Math.abs(parseInt(res.data["bustime-response"]["tm"]) - Date.now())).toBeLessThan(60 * 1000);
+ expect(Math.abs(parseInt(res.data["bustime-response"]["tm"]) - Date.now())).toBeLessThan(2 * 24 * 60 * 60 * 1000);
});
it('should have cached preds in a good state', async () => {
@@ -229,7 +229,7 @@ describe('Reminders', () => {
for (const k in sample) {
expect(k + ":" +typeof x[k]).toBe(k + ":" + typeof sample[k]);
if (k == "prdtm") {
- expect(x[k]).toBeGreaterThanOrEqual(Date.now() - 15 * 1000);
+ expect(x[k]).toBeGreaterThanOrEqual(Date.now() - 2 * 24 * 60 * 60 * 1000);
}
}
};
diff --git a/tsconfig.json b/tsconfig.json
index 144c0e8..3422bf1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,14 +1,14 @@
{
"compilerOptions": {
- "target": "es6",
+ "target": "es2021",
"module": "esnext",
- "moduleResolution": "Node",
+ "moduleResolution": "bundler",
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
- "baseUrl": "./",
+ // "baseUrl": "./",
"paths": {
"@/*": [
"./src/*"
Implementation of the McRAPTOR (Multi-Criteria Round-Based Public Transit Routing) algorithm. -Optimizes for arrival time, walking distance, and number of transfers.
-