Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9a16915
feat(api): add support for defining post routes
jumpy-cat Jul 12, 2026
a41acad
feat(api): support having no req/res bodies
jumpy-cat Jul 12, 2026
d2abdc3
fix(docu): clean up openapi output, adjust comment
jumpy-cat Aug 2, 2026
a8af3e0
feat: test stubs, output to file, flesh out tsdoc comments
jumpy-cat Jul 22, 2026
6f9bc50
ci: generate and upload openapi specs
jumpy-cat Jul 22, 2026
c7bfd7e
refactor(docu): improve testability
jumpy-cat Aug 2, 2026
51210b7
test(docu): add some tests
jumpy-cat Aug 2, 2026
3e08be2
fix(docu): adjust oapi output and path/query type
jumpy-cat Aug 2, 2026
0cce829
feat(docu): improve error ergonomics when calling finalize
jumpy-cat Jul 23, 2026
123bb84
feat(docu): make request errors less verbose
jumpy-cat Jul 24, 2026
6c9ea49
fix(docu): refine type of query & path params
jumpy-cat Jul 24, 2026
7f4123d
test(docu): complete the test suite
jumpy-cat Jul 24, 2026
9429f64
fix(api): remove z.transform from /reminders/:token
jumpy-cat Jul 24, 2026
4fc7f91
test: check if documented is enabled first
jumpy-cat Jul 24, 2026
d832efe
fix(api): restrict success status codes
jumpy-cat Jul 25, 2026
963932e
docs(docu): add example, add note about tuples
jumpy-cat Jul 25, 2026
03c1f2c
fix(docu): support schemas with "id" field
jumpy-cat Jul 25, 2026
d64c8e0
feat(docu): async handlers, optional query params
jumpy-cat Aug 9, 2026
49b2332
test(api): update expected error message
jumpy-cat Aug 15, 2026
2856610
fix(docu): actually support optional query params
jumpy-cat Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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 &
Expand Down Expand Up @@ -58,7 +59,7 @@ jobs:
- name: Build Docs
run: npx typedoc --entryPointStrategy expand ./src --treatWarningsAsErrors
working-directory: ${{ github.workspace }}
- name: Sync files
- name: Sync Files
uses: SamKirkland/FTP-Deploy-Action@v4.4.0
with:
server: ${{ secrets.FTP_SERVER }}
Expand All @@ -67,3 +68,30 @@ jobs:
password: ${{ secrets.FTP_PASSWORD }}
local-dir: ${{ github.workspace }}/docs/
server-dir: ${{ github.ref }}/typedoc/

OpenAPI:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v6
- name: NPM Install
run: npm i
working-directory: ${{ github.workspace }}
- name: Build Spec
run: |
export DOCUMENTED=1
export DOCUMENTED_OUTPUT_FILE=openapi/spec.json
export DOCUMENTED_EXIT_ON_OUTPUT=1
mkdir openapi
npm start
working-directory: ${{ github.workspace }}
- name: Sync Files
uses: SamKirkland/FTP-Deploy-Action@v4.4.0
with:
server: ${{ secrets.FTP_SERVER }}
port: ${{ secrets.FTP_PORT }}
username: ${{ secrets.FTP_USERNAME }}
password: ${{ secrets.FTP_PASSWORD }}
local-dir: ${{ github.workspace }}/openapi/
server-dir: ${{ github.ref }}/openapi/

3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ package-lock.json
.env
.vscode/
src/assets/walkingCache.json
/docs/
/docs/
*.log
9 changes: 5 additions & 4 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import express from "express";

import mbus from "./routes/api"
import { addRouter, dumpReflectionInfo, reflection } from "./routes/helper";
import * as documented from "./routes/documented";

const app = express();

app.use(express.json());
addRouter(app, "/mbus/api/v3", mbus);
documented.addRouter(documented.globalContext, app, "/mbus/api/v3", mbus);
app.use("/docs", express.static("docs"));

const PORT = process.env.PORT || 3000;


app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
if (reflection)
dumpReflectionInfo();
if (documented.ENABLED) {
documented.outputDocsFor(documented.globalContext);
}
});
112 changes: 52 additions & 60 deletions src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as journeyService from '../services/journey';
import * as reminderService from '../services/reminder';
import * as graphBuilder from '../services/graphBuilder';
import { startBackgroundJobs } from '../jobs';
import { addGetRoute, makeSuccessResponse } from "./helper";
import * as documented from "./documented";

/**
* Express router for the MBus API v3.
Expand Down Expand Up @@ -365,41 +365,44 @@ export function getNearestStops(req: express.Request, res: express.Response) {
}
router.get('/nearest-stops', getNearestStops);

/**
* Plans a journey between origin and destination coordinates.
* @param req - Express request
* @param res - Express response
* @returns JSON object with `journeys` array containing possible routes.
*/
export async function planJourney(req: express.Request, res: express.Response) {
try {
const { originLat, originLon, destLat, destLon, walkingPenalty, range } = req.query;

if (!originLat || !originLon || !destLat || !destLon) {
res.status(400).json({ error: 'coordinates are required' });
return;
documented.addGetRoute(
documented.globalContext, router, '/plan-journey',
{
...documented.emptyFormat,
query: z.object({
originLat: z.coerce.number(),
originLon: z.coerce.number(),
destLat: z.coerce.number(),
destLon: z.coerce.number(),
walkingPenalty: z.optional(z.string())
.transform((x) => x === undefined ? undefined : parseFloat(x))
.pipe(z.optional(z.number())),
range: z.optional(z.string())
.transform((x) => x === undefined ? undefined : parseInt(x))
.pipe(z.optional(z.number())),
}),
resBody: z.any(),
},
async (_, { originLat, originLon, destLat, destLon, walkingPenalty, range }) => {
try {
const now = new Date();
const secondsSinceMidnight = now.getUTCHours() * 3600 + now.getUTCMinutes() * 60 + now.getUTCSeconds();

const results = await journeyService.planJourney(
originLat, originLon,
destLat, destLon,
secondsSinceMidnight,
{ walkingPenalty, range },
);

return documented.makeSuccessResponse({ journeys: results });
} catch (error) {
console.error("Journey plan error:", error);
return documented.makeFailureResponse(500, 'Journey planning failed');
}

const now = new Date();
const secondsSinceMidnight = now.getUTCHours() * 3600 + now.getUTCMinutes() * 60 + now.getUTCSeconds();

const results = await journeyService.planJourney(
parseFloat(originLat as string), parseFloat(originLon as string),
parseFloat(destLat as string), parseFloat(destLon as string),
secondsSinceMidnight,
{
walkingPenalty: walkingPenalty ? parseFloat(walkingPenalty as string) : undefined,
range: range ? parseInt(range as string) : undefined
}
);

res.json({ journeys: results });
} catch (error) {
console.error("Journey plan error:", error);
res.status(500).json({ error: 'Journey planning failed' });
}
}
router.get('/plan-journey', planJourney);
},
{ description: 'Plans a journey between origin and destination coordinates.' },
);

/**
* Saves the current graph state to a file (DEV mode only).
Expand Down Expand Up @@ -488,22 +491,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 },
async (_, __, { 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(
Expand All @@ -513,11 +506,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() });
/**
Expand Down Expand Up @@ -568,22 +559,23 @@ export function swapToken(req: express.Request, res: express.Response) {
}
router.post('/swapToken', swapToken);

const Token = z.string().transform(reminderService.registrationToken).meta({ id: "Token" })
const Token = z.string().meta({ id: "Token" })
const ActiveReminder = z.object({
stpid: z.string(),
rtid: z.string(),
thresh: z.number().nullable(),
eta: z.number().nullable(),
}).meta({ id: "Reminder" });

addGetRoute(
router, '/activeReminders/:token',
documented.addGetRoute(
documented.globalContext, router, '/activeReminders/:token',
{
params: z.object({ token: Token }),
query: z.object(),
resBody: z.object({ reminders: z.array(ActiveReminder) }),
},
({ token }, _) => {
async ({ token }, _) => {
const regTok = reminderService.registrationToken(token);
const subscriptionInfo = (r: reminderService.PreThreshold | reminderService.PostThreshold) => {
return {
stpid: r.event.stpid,
Expand All @@ -595,13 +587,13 @@ addGetRoute(
console.log(`Got request for active reminders of ${token}`);
const universityReminders = reminderService
.universityReminderSubscriptions
.activeRemindersFor(token)
.activeRemindersFor(regTok)
.map(subscriptionInfo);
const rideReminders = reminderService
.rideReminderSubscriptions
.activeRemindersFor(token)
.activeRemindersFor(regTok)
.map(subscriptionInfo);
return makeSuccessResponse(200, { reminders: universityReminders.concat(rideReminders) });
return documented.makeSuccessResponse({ reminders: universityReminders.concat(rideReminders) });
},
{
summary: "active reminders",
Expand Down
Loading