Skip to content

Commit 3657adb

Browse files
committed
feat(transit): give live-transit-motis a Transitous instance + prefix routing
live-transit-motis served realtime only from the local MOTIS, yet accepted mo: (Transitous) ids and queried them against local — so alerts/trip updates for Transitous-served trips silently failed, and cloud-only deployments got no realtime at all (no other provider covers Transitous RT). Align it with transit-motis/geocoding-motis: add a Transitous client and route by id prefix (mo: -> Transitous, ms:/other -> local), attributing each result to the matching source. Manifest now mirrors the other two — motis service is optional (works cloud-only), transitousUrl config, a Transitous healthcheck, and the transitous data source (with localized strings).
1 parent 13c3c22 commit 3657adb

5 files changed

Lines changed: 129 additions & 17 deletions

File tree

integrations/live-transit-motis/__tests__/index.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ function createCtx(config: Record<string, unknown> = {}): CtxHandle {
1717
config,
1818
manifest: {
1919
dataSources: [
20+
{
21+
sourceId: "transitous",
22+
name: "Transitous",
23+
url: "https://api.transitous.org/",
24+
license: "Mixed",
25+
providerCountry: "DE",
26+
providerPrivacyUrl: "https://transitous.org/privacy/",
27+
},
2028
{
2129
sourceId: "motis-rt",
2230
name: "MOTIS GTFS-RT Pass-through",
@@ -70,7 +78,9 @@ describe("live-transit-motis provider", () => {
7078
alerts: { byStop: true, byRoute: false, byBbox: false },
7179
tripUpdates: true,
7280
});
73-
expect(provider.attribution[0]?.sourceId).toBe("motis-rt");
81+
expect(provider.attribution.map((a) => a.sourceId)).toEqual(
82+
expect.arrayContaining(["transitous", "motis-rt"]),
83+
);
7484
expect(typeof provider.getAlertsForStop).toBe("function");
7585
expect(typeof provider.getTripUpdate).toBe("function");
7686
expect(provider.getVehiclePositions).toBeUndefined();
@@ -303,4 +313,42 @@ describe("live-transit-motis provider", () => {
303313
expect(mod.__testing.resolveMotisUrl(ctx)).toBe("http://localhost:8081");
304314
});
305315
});
316+
317+
describe("prefix routing", () => {
318+
it("routeForId picks the client + attribution by id prefix", async () => {
319+
const mod = await loadModule();
320+
const { ctx } = createCtx();
321+
mod.setup(ctx);
322+
const { routeForId, transitousClient, localClient } = mod.__testing;
323+
324+
expect(routeForId("mo:NL:123").client).toBe(transitousClient);
325+
expect(routeForId("ms:DE:456").client).toBe(localClient);
326+
expect(routeForId("8000105").client).toBe(localClient);
327+
328+
expect(routeForId("mo:x").attribution[0]?.sourceId).toBe("transitous");
329+
expect(routeForId("ms:x").attribution[0]?.sourceId).toBe("motis-rt");
330+
});
331+
332+
it("queries Transitous for mo: stops and the local instance for ms: stops", async () => {
333+
const motisClient = await import("@motis-project/motis-client");
334+
vi.mocked(motisClient.stoptimes).mockResolvedValue({
335+
data: { place: { alerts: [] } },
336+
} as never);
337+
338+
const mod = await loadModule();
339+
const { ctx, getProvider } = createCtx();
340+
mod.setup(ctx);
341+
const provider = getProvider();
342+
343+
await provider.getAlertsForStop?.("mo:NL:123");
344+
expect(motisClient.stoptimes).toHaveBeenLastCalledWith(
345+
expect.objectContaining({ client: mod.__testing.transitousClient }),
346+
);
347+
348+
await provider.getAlertsForStop?.("ms:DE:456");
349+
expect(motisClient.stoptimes).toHaveBeenLastCalledWith(
350+
expect.objectContaining({ client: mod.__testing.localClient }),
351+
);
352+
});
353+
});
306354
});

integrations/live-transit-motis/index.ts

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { withAttribution } from "@openmapx/mobility-core/result";
2020

2121
const attribution = createManifestAttribution();
2222

23+
import type { Attribution } from "@openmapx/mobility-core/attribution";
2324
import type { AlertSeverity, ServiceAlert } from "@openmapx/mobility-core/transit";
2425

2526
/**
@@ -30,6 +31,7 @@ import type { AlertSeverity, ServiceAlert } from "@openmapx/mobility-core/transi
3031
* through every consumer of the local instance.
3132
*/
3233
const FALLBACK_MOTIS_URL = "http://localhost:8081";
34+
const TRANSITOUS_URL = "https://api.transitous.org";
3335
const TIMEOUT_MS = 8_000;
3436
const PROVIDER_ID = "live-transit-motis";
3537
const SOURCE_ID = "motis-rt";
@@ -56,18 +58,34 @@ function resolveMotisUrl(ctx: IntegrationContext): string {
5658
// any multi-value list. Serialise arrays comma-joined to match the spec.
5759
const QUERY_SERIALIZER = { array: { explode: false, style: "form" } } as const;
5860

59-
const client: Client = (() => {
61+
function makeClient(baseUrl: string): Client {
6062
const c = createClient({
61-
baseUrl: FALLBACK_MOTIS_URL,
63+
baseUrl,
6264
headers: { "User-Agent": USER_AGENT_TRANSIT },
6365
querySerializer: QUERY_SERIALIZER,
6466
});
65-
c.interceptors.request.use((request) => {
66-
const signal = AbortSignal.timeout(TIMEOUT_MS);
67-
return new Request(request, { signal });
68-
});
67+
c.interceptors.request.use(
68+
(request) => new Request(request, { signal: AbortSignal.timeout(TIMEOUT_MS) }),
69+
);
6970
return c;
70-
})();
71+
}
72+
73+
// Two instances so realtime is queried against the SAME MOTIS that produced the
74+
// id: `mo:` ids come from the cloud Transitous instance, everything else (`ms:`)
75+
// from the deployment's self-hosted MOTIS. Routing by prefix (not reachability)
76+
// is required — a `mo:` trip simply does not exist in the local instance, and
77+
// vice versa — and mirrors transit-motis's getVehicleJourney prefix handling.
78+
const localClient = makeClient(FALLBACK_MOTIS_URL);
79+
const transitousClient = makeClient(TRANSITOUS_URL);
80+
81+
function routeForId(id: string): { client: Client; attribution: Attribution[] } {
82+
if (id.startsWith("mo:")) {
83+
const attr = attribution.bySource("transitous");
84+
return { client: transitousClient, attribution: attr ? [attr] : [] };
85+
}
86+
const attr = attribution.bySource("motis-rt");
87+
return { client: localClient, attribution: attr ? [attr] : [] };
88+
}
7189

7290
/** MOTIS id prefixes the local + cloud + RT providers all share. */
7391
const MOTIS_ID_PREFIX_RE = /^(ms:|mo:|mr:)/;
@@ -184,10 +202,14 @@ function mapMotisAlert(alert: MotisAlert, index: number): ServiceAlert {
184202

185203
export function setup(ctx: IntegrationContext): void {
186204
attribution.set(ctx.manifest.dataSources ?? []);
187-
client.setConfig({
205+
localClient.setConfig({
188206
baseUrl: resolveMotisUrl(ctx),
189207
headers: { "User-Agent": USER_AGENT_TRANSIT },
190208
});
209+
transitousClient.setConfig({
210+
baseUrl: (ctx.config.transitousUrl as string | undefined) || TRANSITOUS_URL,
211+
headers: { "User-Agent": USER_AGENT_TRANSIT },
212+
});
191213

192214
const provider: RealtimeProvider = {
193215
id: PROVIDER_ID,
@@ -200,16 +222,17 @@ export function setup(ctx: IntegrationContext): void {
200222
tripUpdates: true,
201223
},
202224
async getAlertsForStop(stopId) {
225+
const { client, attribution: attr } = routeForId(stopId);
203226
try {
204227
const { data } = await stoptimes({
205228
client,
206229
query: { stopId: stripPrefix(stopId), n: 0, window: 0, withAlerts: true },
207230
});
208231
const motisAlerts: MotisAlert[] = data?.place?.alerts ?? [];
209232
const mapped = motisAlerts.map((alert, index) => mapMotisAlert(alert, index));
210-
return withAttribution(mapped, attribution.all(), freshnessNow({ hasRealtimeData: true }));
233+
return withAttribution(mapped, attr, freshnessNow({ hasRealtimeData: true }));
211234
} catch {
212-
return withAttribution([], attribution.all(), freshnessNow({ hasRealtimeData: true }));
235+
return withAttribution([], attr, freshnessNow({ hasRealtimeData: true }));
213236
}
214237
},
215238
async getTripUpdate(tripId, stopId) {
@@ -219,16 +242,17 @@ export function setup(ctx: IntegrationContext): void {
219242
if (!MOTIS_ID_PREFIX_RE.test(tripId)) {
220243
return withAttribution(null, attribution.all(), freshnessNow({ hasRealtimeData: true }));
221244
}
245+
const { client, attribution: attr } = routeForId(tripId);
222246
try {
223247
const { data } = await motisTrip({
224248
client,
225249
query: { tripId: stripPrefix(tripId) },
226250
});
227251
const itinerary = data as Itinerary | undefined;
228252
const delta = itinerary ? deltaFromItinerary(itinerary, tripId, stopId) : null;
229-
return withAttribution(delta, attribution.all(), freshnessNow({ hasRealtimeData: true }));
253+
return withAttribution(delta, attr, freshnessNow({ hasRealtimeData: true }));
230254
} catch {
231-
return withAttribution(null, attribution.all(), freshnessNow({ hasRealtimeData: true }));
255+
return withAttribution(null, attr, freshnessNow({ hasRealtimeData: true }));
232256
}
233257
},
234258
};
@@ -242,5 +266,8 @@ export const __testing = {
242266
mapMotisAlert,
243267
mapMotisAlertSeverity,
244268
resolveMotisUrl,
269+
routeForId,
245270
stripPrefix,
271+
localClient,
272+
transitousClient,
246273
};

integrations/live-transit-motis/manifest.json

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"documentation": "https://motis-project.de/",
77
"domains": ["live-transit"],
88
"dependencies": ["overlay-live-transit"],
9-
"requires": [{ "service": "motis", "optional": false }],
9+
"requires": [{ "service": "motis", "optional": true }],
1010
"backend": {
1111
"routes": false
1212
},
@@ -19,15 +19,26 @@
1919
},
2020
"endpoint": {
2121
"type": "string",
22-
"title": "MOTIS endpoint",
23-
"description": "Base URL of the MOTIS instance. Falls back to the service registry's resolved URL.",
22+
"title": "Local MOTIS endpoint URL",
23+
"description": "Base URL of a self-hosted MOTIS server. Leave blank to rely on the `motis` service registry entry.",
24+
"format": "url"
25+
},
26+
"transitousUrl": {
27+
"type": "string",
28+
"title": "Transitous endpoint URL",
29+
"description": "Base URL of the cloud Transitous instance, used for realtime on `mo:` (Transitous-served) trips and stops.",
2430
"format": "url"
2531
}
2632
}
2733
},
2834
"healthCheck": [
2935
{
30-
"name": "MOTIS endpoint",
36+
"name": "Transitous",
37+
"type": "http",
38+
"url": "https://api.transitous.org/api/v1/geocode?text=test"
39+
},
40+
{
41+
"name": "MOTIS self-hosted",
3142
"type": "ping",
3243
"urlTemplate": "${service:motis}",
3344
"url": "http://localhost:8081"
@@ -36,6 +47,22 @@
3647
"quality": "built-in",
3748
"category": "Transit",
3849
"dataSources": [
50+
{
51+
"sourceId": "transitous",
52+
"name": "Transitous",
53+
"url": "https://api.transitous.org/",
54+
"license": "Mixed (per upstream GTFS/GBFS feed; mostly open data)",
55+
"licenseUrl": "https://transitous.org/sources/",
56+
"commercialUse": "conditional",
57+
"attribution": "Public transport data via Transitous — see transitous.org/sources/",
58+
"notes": "The Transitous public API (api.transitous.org) is 'not intended for commercial or for-profit purposes' — commercial use is decided case-by-case on request, and open-source clients must publish their source and visibly link transitous.org/sources/ for attribution (see transitous.org/api/). The MOTIS routing software is AGPL-3.0-or-later, but that licenses the software, not the data; the aggregated timetable data is per upstream feed (mostly CC-BY / CC-BY-SA / open-government licences).",
59+
"providerCountry": "DE",
60+
"providerPrivacyUrl": "https://transitous.org/privacy/",
61+
"endUserExposure": "server-only",
62+
"personalData": false,
63+
"cookies": false,
64+
"dpaAvailable": false
65+
},
3966
{
4067
"sourceId": "motis-rt",
4168
"name": "MOTIS GTFS-RT Pass-through",

integrations/live-transit-motis/strings/de.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
"name": "MOTIS GTFS-RT",
33
"description": "Live-Meldungen und Fahrplanaktualisierungen von MOTIS",
44
"dataSources": {
5+
"transitous": {
6+
"purpose": "Echtzeit-Verkehrsmeldungen und Fahrplanaktualisierungen für Verbindungen aus dem globalen Transitous-Netz — verwendet, wenn eine Fahrt oder Haltestelle von Transitous stammt (eine `mo:`-ID) und nicht von der selbst gehosteten MOTIS-Instanz",
7+
"dataSent": "Eine Haltestellen-ID (für Meldungen) oder Fahrt-ID (für Fahrplanaktualisierungen) für von Transitous bediente Verbindungen wird an api.transitous.org übermittelt; Nutzer- oder Standortdaten werden nicht übermittelt.",
8+
"dataReceived": "Verkehrsmeldungen (Titel, Beschreibung, Schweregrad, Auswirkung, Gültigkeitszeiträume) sowie fahrtbezogene Abweichungen (erwartete Zeiten, Verspätung in Sekunden, Gleis, Ausfallstatus)"
9+
},
510
"motis-rt": {
611
"purpose": "Echtzeit-Verkehrsmeldungen und Fahrplanaktualisierungen (Verspätungen, Gleiswechsel, Ausfälle), die aus den von MOTIS eingelesenen GTFS-RT-Feeds durchgereicht werden",
712
"dataSent": "Es werden keine Daten an Dritte übermittelt. Die Anfragen gehen an die eigene MOTIS-Instanz von OpenMapX und enthalten lediglich eine interne Haltestellen-ID (für Meldungen) oder Fahrt-ID (für Fahrplanaktualisierungen); Nutzer- oder Standortdaten werden nicht übermittelt.",

integrations/live-transit-motis/strings/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
"name": "MOTIS GTFS-RT",
33
"description": "Live alerts and trip updates from MOTIS",
44
"dataSources": {
5+
"transitous": {
6+
"purpose": "Realtime service alerts and trip updates for journeys served by the global Transitous network — used when a trip or stop comes from Transitous (a `mo:` id) rather than the self-hosted MOTIS instance",
7+
"dataSent": "A stop ID (for alerts) or trip ID (for trip updates) for Transitous-served journeys is sent to api.transitous.org; no user or location data is included.",
8+
"dataReceived": "Service alerts (title, description, severity, effect, validity periods) and trip-level deltas (expected times, delay in seconds, platform, cancellation status)"
9+
},
510
"motis-rt": {
611
"purpose": "Realtime service alerts and trip updates (delays, platform changes, cancellations) passed through from GTFS-RT feeds ingested by MOTIS",
712
"dataSent": "Nothing is sent to a third party. Requests go to OpenMapX's own MOTIS deployment and carry only an internal stop ID (for alerts) or trip ID (for trip updates); no user or location data is included.",

0 commit comments

Comments
 (0)