diff --git a/README.md b/README.md index d55894d..22d0649 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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(...)` diff --git a/index.test.ts b/index.test.ts index a215e0b..a718dd7 100644 --- a/index.test.ts +++ b/index.test.ts @@ -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 }, diff --git a/index.ts b/index.ts index a01f86c..7d6ee13 100644 --- a/index.ts +++ b/index.ts @@ -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"; @@ -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; @@ -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() { @@ -139,6 +152,24 @@ class SubwayClient { return this.mta.hostedJson("/api/v1/subway/direction", query); } + arrivalBoard(query: SubwayArrivalBoardQuery): Promise { + return this.mta.hostedJson("/api/v1/subway/arrival-board", { + ...query, + route: query.route ? normalizeRouteId(query.route) : undefined, + }); + } + + routeStations(query: SubwayRouteStationsQuery): Promise { + return this.mta.hostedJson( + `/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); @@ -296,6 +327,20 @@ class BusClient { .slice(0, query.limit ?? 50); } + arrivalBoard(query: BusArrivalBoardQuery): Promise { + return this.mta.hostedJson("/api/v1/bus/arrival-board", query); + } + + routeStops(query: BusRouteStopsQuery): Promise { + return this.mta.hostedJson( + `/api/v1/bus/routes/${encodeURIComponent(query.route)}/stops`, + { + ...query, + route: undefined, + }, + ); + } + private requireKey() { if (!this.mta.busTimeKey) throw new MissingBusTimeKeyError(); return this.mta.busTimeKey; @@ -361,6 +406,18 @@ class StopsClient { return this.mta.static.stopsNear(query); }); } + + byIds(query: StopsByIdsQuery): Promise { + return this.mta.hostedJson("/api/v1/stops", query); + } +} + +class RoutesClient { + constructor(private readonly mta: MTA) {} + + list(query: RoutesListQuery = {}): Promise { + return this.mta.hostedJson("/api/v1/routes", query); + } } function serializeHostedQuery(query: object) { diff --git a/src/types.ts b/src/types.ts index 3f2b28a..171d70b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,9 +19,11 @@ export type SubwayStopId = AutocompleteString; export type BusStopId = AutocompleteString; export type TransitMode = "subway" | "bus" | "lirr" | "metro-north"; +export type StopMode = "subway" | "bus"; export type Direction = "north" | "south" | "east" | "west" | "unknown"; export type SubwayResolvedDirection = "north" | "south"; +export type SubwayDirectionAlias = Direction | "uptown" | "downtown"; export type DirectionHeadsigns = Record; @@ -157,6 +159,17 @@ export type NearbyStop = Stop & { note?: string; }; +export type StopLookup = { + requestedId: string; + found: boolean; + stop?: Stop; + servedRoutes?: ServedRoute[]; +}; + +export type RouteCatalogEntry = Route & { + mode: StopMode; +}; + export interface Arrival { mode: TransitMode; route: Route; @@ -207,11 +220,33 @@ export interface Alert { export interface SubwayArrivalQuery { stopId: SubwayStopId; route?: SubwayRoute; - direction?: Direction | "uptown" | "downtown"; + direction?: SubwayDirectionAlias; limit?: number; includeRaw?: boolean; } +export interface SubwayArrivalBoardQuery { + lat: number; + lon: number; + route?: SubwayRoute; + radiusMeters?: number; + limitStations?: number; + limitArrivals?: number; + includeRaw?: boolean; +} + +export type ArrivalBoardDirection = { + direction: Direction; + headsign?: string; + arrivals: Arrival[]; +}; + +export type SubwayArrivalBoardStation = { + station: Stop; + distanceMeters: number; + directions: ArrivalBoardDirection[]; +}; + export interface SubwayDirectionQuery { route: SubwayRoute; fromStopId: SubwayStopId; @@ -239,6 +274,28 @@ export interface BusArrivalQuery { includeRaw?: boolean; } +export interface BusArrivalBoardQuery { + lat: number; + lon: number; + route?: BusRoute; + radiusMeters?: number; + limitStops?: number; + limitArrivals?: number; + includeRaw?: boolean; +} + +export type BusArrivalBoardRoute = { + route: Route; + headsign?: string; + arrivals: Arrival[]; +}; + +export type BusArrivalBoardStop = { + stop: Stop; + distanceMeters: number; + routes: BusArrivalBoardRoute[]; +}; + export interface BusVehicleQuery { route?: BusRoute; vehicleId?: string; @@ -262,3 +319,46 @@ export interface StopsNearQuery { radiusMeters?: number; limit?: number; } + +export interface StopsByIdsQuery { + ids: StopId[]; + includeRoutes?: boolean; +} + +export interface RoutesListQuery { + modes?: StopMode[]; +} + +export type RoutePatternStop = Stop & { + arrivals?: Arrival[]; +}; + +export type RoutePattern = { + direction: string; + headsigns?: string[]; + stops: RoutePatternStop[]; +}; + +export type RouteStopsResponse = { + route: Route; + mode: StopMode; + directions: RoutePattern[]; +}; + +export interface SubwayRouteStationsQuery { + route: SubwayRoute; + direction?: SubwayDirectionAlias; + includeArrivals?: boolean; + limitArrivals?: number; + limitStops?: number; + includeRaw?: boolean; +} + +export interface BusRouteStopsQuery { + route: BusRoute; + direction?: number | string; + includeArrivals?: boolean; + limitArrivals?: number; + limitStops?: number; + includeRaw?: boolean; +}