Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,40 @@ const direction = await mta.subway.direction({
fromStopId: "L06",
destination: "Union Sq",
});

const subwayBoard = await mta.subway.arrivalBoard({
lat: 40.7356,
lon: -73.9906,
limitStations: 5,
limitArrivals: 3,
});

const busBoard = await mta.bus.arrivalBoard({
lat: 40.7421,
lon: -73.9914,
route: "M23",
limitStops: 8,
limitArrivals: 3,
});

const favoriteStops = await mta.stops.byIds({
ids: ["A27", "L06", "308214"],
includeRoutes: true,
});

const routes = await mta.routes.list({
modes: ["subway", "bus"],
});

const lStations = await mta.subway.routeStations({
route: "L",
direction: "uptown",
});

const m23Stops = await mta.bus.routeStops({
route: "M23",
direction: 0,
});
```

Arrival rows may include display-oriented fields. `destination` depends on
Expand Down Expand Up @@ -115,8 +149,14 @@ override them.
## Endpoints

- `mta.subway.arrivals(...)`
- `mta.subway.arrivalBoard(...)`
- `mta.subway.direction(...)`
- `mta.subway.routeStations(...)`
- `mta.bus.arrivals(...)`
- `mta.bus.arrivalBoard(...)`
- `mta.bus.routeStops(...)`
- `mta.bus.vehicles(...)`
- `mta.alerts.current(...)`
- `mta.routes.list(...)`
- `mta.stops.byIds(...)`
- `mta.stops.near(...)`
175 changes: 175 additions & 0 deletions index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,181 @@ test("hosted subway direction calls resolve intermediate destinations through th
});
});

test("hosted expansion methods call the managed API endpoints", async () => {
const requests: Request[] = [];
const mta = new MTA({
apiKey: "test-key",
apiBaseUrl: "https://api.example.com",
fetch: (async (input, init) => {
const request = input instanceof Request ? new Request(input, init) : new Request(String(input), init);
requests.push(request);

const url = new URL(request.url);
if (url.pathname === "/api/v1/subway/arrival-board") {
return Response.json([
{
station: { id: "L06", name: "1 Av", displayName: "1 Av" },
distanceMeters: 120,
directions: [],
},
]);
}
if (url.pathname === "/api/v1/bus/arrival-board") {
return Response.json([
{
stop: { id: "903002", name: "Avenue C/E 20 St" },
distanceMeters: 80,
routes: [],
},
]);
}
if (url.pathname === "/api/v1/stops") {
return Response.json([
{
requestedId: "L06",
found: true,
stop: { id: "L06", name: "1 Av", displayName: "1 Av" },
},
]);
}
if (url.pathname === "/api/v1/routes") {
return Response.json([{ id: "L", shortName: "L", mode: "subway" }]);
}
if (url.pathname === "/api/v1/subway/routes/L/stations") {
return Response.json({
route: { id: "L", shortName: "L" },
mode: "subway",
directions: [],
});
}
if (url.pathname === "/api/v1/bus/routes/M23/stops") {
return Response.json({
route: { id: "M23+", shortName: "M23-SBS" },
mode: "bus",
directions: [],
});
}

return Response.json({ error: "unexpected path", path: url.pathname }, { status: 404 });
}) as typeof fetch,
});

await expect(
mta.subway.arrivalBoard({
lat: 40.7356,
lon: -73.9906,
route: "l",
limitStations: 5,
limitArrivals: 3,
}),
).resolves.toHaveLength(1);
await expect(
mta.bus.arrivalBoard({
lat: 40.7421,
lon: -73.9914,
route: "M23",
limitStops: 8,
limitArrivals: 3,
}),
).resolves.toHaveLength(1);
await expect(
mta.stops.byIds({
ids: ["A27", "L06", "308214"],
includeRoutes: true,
}),
).resolves.toHaveLength(1);
await expect(mta.routes.list({ modes: ["subway", "bus"] })).resolves.toHaveLength(1);
await expect(
mta.subway.routeStations({
route: "L",
direction: "east",
includeArrivals: true,
limitStops: 12,
limitArrivals: 2,
}),
).resolves.toMatchObject({ mode: "subway" });
await expect(
mta.bus.routeStops({
route: "M23",
direction: 0,
includeArrivals: true,
limitStops: 12,
limitArrivals: 2,
}),
).resolves.toMatchObject({ mode: "bus" });

const urls = requests.map((request) => new URL(request.url));
expect(urls.map((url) => url.pathname)).toEqual([
"/api/v1/subway/arrival-board",
"/api/v1/bus/arrival-board",
"/api/v1/stops",
"/api/v1/routes",
"/api/v1/subway/routes/L/stations",
"/api/v1/bus/routes/M23/stops",
]);
const [
subwayBoardUrl,
busBoardUrl,
stopsUrl,
routesUrl,
subwayRouteStationsUrl,
busRouteStopsUrl,
] = urls as [URL, URL, URL, URL, URL, URL];
expect(subwayBoardUrl.searchParams.get("route")).toBe("L");
expect(busBoardUrl.searchParams.get("route")).toBe("M23");
expect(stopsUrl.searchParams.get("ids")).toBe("A27,L06,308214");
expect(stopsUrl.searchParams.get("includeRoutes")).toBe("true");
expect(routesUrl.searchParams.get("modes")).toBe("subway,bus");
expect(subwayRouteStationsUrl.searchParams.get("direction")).toBe("south");
expect(subwayRouteStationsUrl.searchParams.get("limitStops")).toBe("12");
expect(subwayRouteStationsUrl.searchParams.has("route")).toBe(false);
expect(busRouteStopsUrl.searchParams.get("direction")).toBe("0");
expect(busRouteStopsUrl.searchParams.get("limitArrivals")).toBe("2");
expect(busRouteStopsUrl.searchParams.has("route")).toBe(false);
});

test("hosted bus expansion methods preserve exact generated route filters", async () => {
const requests: Request[] = [];
const mta = new MTA({
apiKey: "test-key",
apiBaseUrl: "https://api.example.com",
fetch: (async (input, init) => {
const request = input instanceof Request ? new Request(input, init) : new Request(String(input), init);
requests.push(request);

const url = new URL(request.url);
if (url.pathname === "/api/v1/bus/arrival-board") {
return Response.json([]);
}
if (url.pathname === "/api/v1/bus/routes/M15/stops") {
return Response.json({
route: { id: "M15", shortName: "M15" },
mode: "bus",
directions: [],
});
}

return Response.json({ error: "unexpected path", path: url.pathname }, { status: 404 });
}) as typeof fetch,
});

await mta.bus.arrivalBoard({
lat: 40.7421,
lon: -73.9914,
route: "M15",
});
await mta.bus.routeStops({
route: "M15",
});

const [arrivalBoardUrl, routeStopsUrl] = requests.map((request) => new URL(request.url)) as [
URL,
URL,
];
expect(arrivalBoardUrl.searchParams.get("route")).toBe("M15");
expect(routeStopsUrl.pathname).toBe("/api/v1/bus/routes/M15/stops");
});

test("subway arrivals decode GTFS realtime and join in-memory static metadata", async () => {
const feed = encodeFeedMessage({
header: { gtfsRealtimeVersion: "2.0", timestamp: 1_700_000_000 },
Expand Down
57 changes: 57 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,28 @@ import type {
AlertQuery,
Arrival,
BusArrivalQuery,
BusArrivalBoardQuery,
BusArrivalBoardStop,
BusRouteStopsQuery,
BusVehicleQuery,
Direction,
MTAEndpoints,
MTAOptions,
NearbyStop,
Route,
RouteCatalogEntry,
RouteStopsResponse,
RoutesListQuery,
Stop,
StopLookup,
StopsByIdsQuery,
StopsNearQuery,
SubwayArrivalQuery,
SubwayArrivalBoardQuery,
SubwayArrivalBoardStation,
SubwayDirectionQuery,
SubwayDirectionResolution,
SubwayRouteStationsQuery,
TransitMode,
Vehicle,
} from "./src/types";
Expand All @@ -29,6 +40,7 @@ export class MTA {
readonly bus: BusClient;
readonly alerts: AlertsClient;
readonly stops: StopsClient;
readonly routes: RoutesClient;

readonly fetch: typeof fetch;
readonly now: () => Date;
Expand Down Expand Up @@ -62,6 +74,7 @@ export class MTA {
this.bus = new BusClient(this);
this.alerts = new AlertsClient(this);
this.stops = new StopsClient(this);
this.routes = new RoutesClient(this);
}

async ready() {
Expand Down Expand Up @@ -139,6 +152,24 @@ class SubwayClient {
return this.mta.hostedJson<SubwayDirectionResolution>("/api/v1/subway/direction", query);
}

arrivalBoard(query: SubwayArrivalBoardQuery): Promise<SubwayArrivalBoardStation[]> {
return this.mta.hostedJson<SubwayArrivalBoardStation[]>("/api/v1/subway/arrival-board", {
...query,
route: query.route ? normalizeRouteId(query.route) : undefined,
});
}

routeStations(query: SubwayRouteStationsQuery): Promise<RouteStopsResponse> {
return this.mta.hostedJson<RouteStopsResponse>(
`/api/v1/subway/routes/${encodeURIComponent(normalizeRouteId(query.route))}/stations`,
{
...query,
route: undefined,
direction: normalizeDirection(query.direction, query.route),
},
);
}

private feedForRoute(route: string) {
const feed = this.mta.endpoints.subwayFeeds[route];
if (!feed) throw new UnknownRouteError(route);
Expand Down Expand Up @@ -296,6 +327,20 @@ class BusClient {
.slice(0, query.limit ?? 50);
}

arrivalBoard(query: BusArrivalBoardQuery): Promise<BusArrivalBoardStop[]> {
return this.mta.hostedJson<BusArrivalBoardStop[]>("/api/v1/bus/arrival-board", query);
}

routeStops(query: BusRouteStopsQuery): Promise<RouteStopsResponse> {
return this.mta.hostedJson<RouteStopsResponse>(
`/api/v1/bus/routes/${encodeURIComponent(query.route)}/stops`,
{
...query,
route: undefined,
},
);
}

private requireKey() {
if (!this.mta.busTimeKey) throw new MissingBusTimeKeyError();
return this.mta.busTimeKey;
Expand Down Expand Up @@ -361,6 +406,18 @@ class StopsClient {
return this.mta.static.stopsNear(query);
});
}

byIds(query: StopsByIdsQuery): Promise<StopLookup[]> {
return this.mta.hostedJson<StopLookup[]>("/api/v1/stops", query);
}
}

class RoutesClient {
constructor(private readonly mta: MTA) {}

list(query: RoutesListQuery = {}): Promise<RouteCatalogEntry[]> {
return this.mta.hostedJson<RouteCatalogEntry[]>("/api/v1/routes", query);
}
}

function serializeHostedQuery(query: object) {
Expand Down
Loading
Loading