feat: normalize live PRIM departures #12
Merged@github-anthodev wants to merge
feature/normalize-live-departures/backend/4 into developMerged as
5a0b437da030.22 files changed
added
.github/workflows/test.yml +27 -0 | new file mode 100644 | ||
| @@ -0,0 +1,27 @@ | ||
| 1 | +name: Tests | |
| 2 | + | |
| 3 | +on: | |
| 4 | + pull_request: | |
| 5 | + branches: [develop] | |
| 6 | + | |
| 7 | +permissions: | |
| 8 | + contents: read | |
| 9 | + | |
| 10 | +concurrency: | |
| 11 | + group: ${{ github.workflow }}-${{ github.ref }} | |
| 12 | + cancel-in-progress: true | |
| 13 | + | |
| 14 | +jobs: | |
| 15 | + test: | |
| 16 | + runs-on: ubuntu-latest | |
| 17 | + steps: | |
| 18 | + - uses: actions/checkout@v4 | |
| 19 | + | |
| 20 | + - uses: actions/setup-node@v4 | |
| 21 | + with: | |
| 22 | + node-version-file: .node-version | |
| 23 | + cache: npm | |
| 24 | + | |
| 25 | + - run: npm ci | |
| 26 | + | |
| 27 | + - run: npm test | |
| new file mode 100644 | |||
| @@ -0,0 +1,27 @@ | |||
| 1 | +name: Tests | ||
| 2 | + | ||
| 3 | +on: | ||
| 4 | + pull_request: | ||
| 5 | + branches: [develop] | ||
| 6 | + | ||
| 7 | +permissions: | ||
| 8 | + contents: read | ||
| 9 | + | ||
| 10 | +concurrency: | ||
| 11 | + group: ${{ github.workflow }}-${{ github.ref }} | ||
| 12 | + cancel-in-progress: true | ||
| 13 | + | ||
| 14 | +jobs: | ||
| 15 | + test: | ||
| 16 | + runs-on: ubuntu-latest | ||
| 17 | + steps: | ||
| 18 | + - uses: actions/checkout@v4 | ||
| 19 | + | ||
| 20 | + - uses: actions/setup-node@v4 | ||
| 21 | + with: | ||
| 22 | + node-version-file: .node-version | ||
| 23 | + cache: npm | ||
| 24 | + | ||
| 25 | + - run: npm ci | ||
| 26 | + | ||
| 27 | + - run: npm test | ||
modified
.gitignore +3 -0 | @@ -1,4 +1,7 @@ | ||
| 1 | 1 | node_modules/ |
| 2 | +.env | |
| 3 | +.env.* | |
| 4 | +!.env.example | |
| 2 | 5 | var/ |
| 3 | 6 | packages/watch/build/ |
| 4 | 7 | packages/watch/src/pkjs/ |
| @@ -1,4 +1,7 @@ | |||
| 1 | node_modules/ | 1 | node_modules/ |
| 2 | +.env | ||
| 3 | +.env.* | ||
| 4 | +!.env.example | ||
| 2 | var/ | 5 | var/ |
| 3 | packages/watch/build/ | 6 | packages/watch/build/ |
| 4 | packages/watch/src/pkjs/ | 7 | packages/watch/src/pkjs/ |
added
fixtures/departures/prim/bus.json +42 -0 | new file mode 100644 | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:45Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C1001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:1001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:1001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:32:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:31:00Z", | |
| 19 | + "DepartureStatus": "ontime" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "RecordedAtTime": "2026-01-15T08:29:40Z", | |
| 25 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | |
| 26 | + "MonitoredVehicleJourney": { | |
| 27 | + "LineRef": { "value": "IDFM:C1001" }, | |
| 28 | + "DirectionRef": { "value": "IDFM:1001D" }, | |
| 29 | + "DestinationRef": { "value": "IDFM:1001DST" }, | |
| 30 | + "MonitoredCall": { | |
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:38:00Z", | |
| 32 | + "AimedDepartureTime": "2026-01-15T08:37:30Z", | |
| 33 | + "DepartureStatus": "ontime" | |
| 34 | + } | |
| 35 | + } | |
| 36 | + } | |
| 37 | + ] | |
| 38 | + } | |
| 39 | + ] | |
| 40 | + } | |
| 41 | + } | |
| 42 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:45Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C1001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:1001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:1001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:32:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:31:00Z", | ||
| 19 | + "DepartureStatus": "ontime" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + }, | ||
| 23 | + { | ||
| 24 | + "RecordedAtTime": "2026-01-15T08:29:40Z", | ||
| 25 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | ||
| 26 | + "MonitoredVehicleJourney": { | ||
| 27 | + "LineRef": { "value": "IDFM:C1001" }, | ||
| 28 | + "DirectionRef": { "value": "IDFM:1001D" }, | ||
| 29 | + "DestinationRef": { "value": "IDFM:1001DST" }, | ||
| 30 | + "MonitoredCall": { | ||
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:38:00Z", | ||
| 32 | + "AimedDepartureTime": "2026-01-15T08:37:30Z", | ||
| 33 | + "DepartureStatus": "ontime" | ||
| 34 | + } | ||
| 35 | + } | ||
| 36 | + } | ||
| 37 | + ] | ||
| 38 | + } | ||
| 39 | + ] | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | +} | ||
added
fixtures/departures/prim/cancelled.json +56 -0 | new file mode 100644 | ||
| @@ -0,0 +1,56 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:57Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:46Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C3001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:3001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:3001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:32:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:31:30Z", | |
| 19 | + "DepartureStatus": "cancelled" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "RecordedAtTime": "2026-01-15T08:29:45Z", | |
| 25 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | |
| 26 | + "MonitoredVehicleJourney": { | |
| 27 | + "LineRef": { "value": "IDFM:C3001" }, | |
| 28 | + "DirectionRef": { "value": "IDFM:3001D" }, | |
| 29 | + "DestinationRef": { "value": "IDFM:3001DST" }, | |
| 30 | + "MonitoredCall": { | |
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:35:00Z", | |
| 32 | + "AimedDepartureTime": "2026-01-15T08:34:30Z", | |
| 33 | + "DepartureStatus": "ontime" | |
| 34 | + } | |
| 35 | + } | |
| 36 | + }, | |
| 37 | + { | |
| 38 | + "RecordedAtTime": "2026-01-15T08:29:44Z", | |
| 39 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | |
| 40 | + "MonitoredVehicleJourney": { | |
| 41 | + "LineRef": { "value": "IDFM:C3001" }, | |
| 42 | + "DirectionRef": { "value": "IDFM:3001D" }, | |
| 43 | + "DestinationRef": { "value": "IDFM:3001DST" }, | |
| 44 | + "MonitoredCall": { | |
| 45 | + "ExpectedDepartureTime": "2026-01-15T08:40:00Z", | |
| 46 | + "AimedDepartureTime": "2026-01-15T08:39:30Z", | |
| 47 | + "DepartureStatus": "ontime" | |
| 48 | + } | |
| 49 | + } | |
| 50 | + } | |
| 51 | + ] | |
| 52 | + } | |
| 53 | + ] | |
| 54 | + } | |
| 55 | + } | |
| 56 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:57Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:46Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C3001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:3001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:3001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:32:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:31:30Z", | ||
| 19 | + "DepartureStatus": "cancelled" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + }, | ||
| 23 | + { | ||
| 24 | + "RecordedAtTime": "2026-01-15T08:29:45Z", | ||
| 25 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | ||
| 26 | + "MonitoredVehicleJourney": { | ||
| 27 | + "LineRef": { "value": "IDFM:C3001" }, | ||
| 28 | + "DirectionRef": { "value": "IDFM:3001D" }, | ||
| 29 | + "DestinationRef": { "value": "IDFM:3001DST" }, | ||
| 30 | + "MonitoredCall": { | ||
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:35:00Z", | ||
| 32 | + "AimedDepartureTime": "2026-01-15T08:34:30Z", | ||
| 33 | + "DepartureStatus": "ontime" | ||
| 34 | + } | ||
| 35 | + } | ||
| 36 | + }, | ||
| 37 | + { | ||
| 38 | + "RecordedAtTime": "2026-01-15T08:29:44Z", | ||
| 39 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | ||
| 40 | + "MonitoredVehicleJourney": { | ||
| 41 | + "LineRef": { "value": "IDFM:C3001" }, | ||
| 42 | + "DirectionRef": { "value": "IDFM:3001D" }, | ||
| 43 | + "DestinationRef": { "value": "IDFM:3001DST" }, | ||
| 44 | + "MonitoredCall": { | ||
| 45 | + "ExpectedDepartureTime": "2026-01-15T08:40:00Z", | ||
| 46 | + "AimedDepartureTime": "2026-01-15T08:39:30Z", | ||
| 47 | + "DepartureStatus": "ontime" | ||
| 48 | + } | ||
| 49 | + } | ||
| 50 | + } | ||
| 51 | + ] | ||
| 52 | + } | ||
| 53 | + ] | ||
| 54 | + } | ||
| 55 | + } | ||
| 56 | +} | ||
added
fixtures/departures/prim/delayed.json +28 -0 | new file mode 100644 | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:52Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:2001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C2001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:2001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:2001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:37:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:33:00Z", | |
| 19 | + "DepartureStatus": "delayed" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + } | |
| 23 | + ] | |
| 24 | + } | |
| 25 | + ] | |
| 26 | + } | |
| 27 | + } | |
| 28 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:52Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:2001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C2001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:2001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:2001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:37:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:33:00Z", | ||
| 19 | + "DepartureStatus": "delayed" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + } | ||
| 23 | + ] | ||
| 24 | + } | ||
| 25 | + ] | ||
| 26 | + } | ||
| 27 | + } | ||
| 28 | +} | ||
added
fixtures/departures/prim/malformed.json +26 -0 | new file mode 100644 | ||
| @@ -0,0 +1,26 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:52Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:2001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C2001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:2001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:2001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "DepartureStatus": "ontime" | |
| 18 | + } | |
| 19 | + } | |
| 20 | + } | |
| 21 | + ] | |
| 22 | + } | |
| 23 | + ] | |
| 24 | + } | |
| 25 | + } | |
| 26 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,26 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:52Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:2001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C2001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:2001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:2001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "DepartureStatus": "ontime" | ||
| 18 | + } | ||
| 19 | + } | ||
| 20 | + } | ||
| 21 | + ] | ||
| 22 | + } | ||
| 23 | + ] | ||
| 24 | + } | ||
| 25 | + } | ||
| 26 | +} | ||
added
fixtures/departures/prim/metro.json +28 -0 | new file mode 100644 | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:50Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:2001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C2001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:2001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:2001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:34:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:33:00Z", | |
| 19 | + "DepartureStatus": "ontime" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + } | |
| 23 | + ] | |
| 24 | + } | |
| 25 | + ] | |
| 26 | + } | |
| 27 | + } | |
| 28 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:50Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:2001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C2001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:2001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:2001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:34:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:33:00Z", | ||
| 19 | + "DepartureStatus": "ontime" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + } | ||
| 23 | + ] | ||
| 24 | + } | ||
| 25 | + ] | ||
| 26 | + } | ||
| 27 | + } | ||
| 28 | +} | ||
added
fixtures/departures/prim/mixed.json +54 -0 | new file mode 100644 | ||
| @@ -0,0 +1,54 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:57Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:44Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C4001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:4001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:33:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:32:30Z", | |
| 19 | + "DepartureStatus": "ontime" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "RecordedAtTime": "2026-01-15T08:29:43Z", | |
| 25 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | |
| 26 | + "MonitoredVehicleJourney": { | |
| 27 | + "LineRef": { "value": "IDFM:C4001" }, | |
| 28 | + "DirectionRef": { "value": "IDFM:4001D" }, | |
| 29 | + "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 30 | + "MonitoredCall": { | |
| 31 | + "AimedDepartureTime": "2026-01-15T08:41:00Z" | |
| 32 | + } | |
| 33 | + } | |
| 34 | + }, | |
| 35 | + { | |
| 36 | + "RecordedAtTime": "2026-01-15T08:29:42Z", | |
| 37 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | |
| 38 | + "MonitoredVehicleJourney": { | |
| 39 | + "LineRef": { "value": "IDFM:C4001" }, | |
| 40 | + "DirectionRef": { "value": "IDFM:4001D" }, | |
| 41 | + "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 42 | + "MonitoredCall": { | |
| 43 | + "ExpectedDepartureTime": "2026-01-15T08:49:00Z", | |
| 44 | + "AimedDepartureTime": "2026-01-15T08:48:00Z", | |
| 45 | + "DepartureStatus": "ontime" | |
| 46 | + } | |
| 47 | + } | |
| 48 | + } | |
| 49 | + ] | |
| 50 | + } | |
| 51 | + ] | |
| 52 | + } | |
| 53 | + } | |
| 54 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:57Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:44Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C4001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:4001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:4001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:33:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:32:30Z", | ||
| 19 | + "DepartureStatus": "ontime" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + }, | ||
| 23 | + { | ||
| 24 | + "RecordedAtTime": "2026-01-15T08:29:43Z", | ||
| 25 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | ||
| 26 | + "MonitoredVehicleJourney": { | ||
| 27 | + "LineRef": { "value": "IDFM:C4001" }, | ||
| 28 | + "DirectionRef": { "value": "IDFM:4001D" }, | ||
| 29 | + "DestinationRef": { "value": "IDFM:4001DST" }, | ||
| 30 | + "MonitoredCall": { | ||
| 31 | + "AimedDepartureTime": "2026-01-15T08:41:00Z" | ||
| 32 | + } | ||
| 33 | + } | ||
| 34 | + }, | ||
| 35 | + { | ||
| 36 | + "RecordedAtTime": "2026-01-15T08:29:42Z", | ||
| 37 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | ||
| 38 | + "MonitoredVehicleJourney": { | ||
| 39 | + "LineRef": { "value": "IDFM:C4001" }, | ||
| 40 | + "DirectionRef": { "value": "IDFM:4001D" }, | ||
| 41 | + "DestinationRef": { "value": "IDFM:4001DST" }, | ||
| 42 | + "MonitoredCall": { | ||
| 43 | + "ExpectedDepartureTime": "2026-01-15T08:49:00Z", | ||
| 44 | + "AimedDepartureTime": "2026-01-15T08:48:00Z", | ||
| 45 | + "DepartureStatus": "ontime" | ||
| 46 | + } | ||
| 47 | + } | ||
| 48 | + } | ||
| 49 | + ] | ||
| 50 | + } | ||
| 51 | + ] | ||
| 52 | + } | ||
| 53 | + } | ||
| 54 | +} | ||
added
fixtures/departures/prim/partial.json +69 -0 | new file mode 100644 | ||
| @@ -0,0 +1,69 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "StopMonitoringDelivery": [ | |
| 5 | + { | |
| 6 | + "MonitoredStopVisit": [ | |
| 7 | + { | |
| 8 | + "RecordedAtTime": "2026-01-15T08:29:36Z", | |
| 9 | + "MonitoringRef": { "value": "IDFM:SP:1099" }, | |
| 10 | + "MonitoredVehicleJourney": { | |
| 11 | + "LineRef": { "value": "IDFM:C1001" }, | |
| 12 | + "DirectionRef": { "value": "IDFM:1099D" }, | |
| 13 | + "DestinationRef": { "value": "IDFM:1099DST" }, | |
| 14 | + "MonitoredCall": { | |
| 15 | + "ExpectedDepartureTime": "2026-01-15T08:31:00Z", | |
| 16 | + "AimedDepartureTime": "2026-01-15T08:31:00Z", | |
| 17 | + "DepartureStatus": "ontime" | |
| 18 | + } | |
| 19 | + } | |
| 20 | + }, | |
| 21 | + { | |
| 22 | + "RecordedAtTime": "2026-01-15T08:29:35Z", | |
| 23 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | |
| 24 | + "MonitoredVehicleJourney": { | |
| 25 | + "LineRef": { "value": "IDFM:C1001" }, | |
| 26 | + "DirectionRef": { "value": "IDFM:1001D" }, | |
| 27 | + "DestinationRef": { "value": "IDFM:1001DST" }, | |
| 28 | + "MonitoredCall": { | |
| 29 | + "ExpectedDepartureTime": "2026-01-15T08:34:00Z", | |
| 30 | + "AimedDepartureTime": "2026-01-15T08:33:00Z", | |
| 31 | + "DepartureStatus": "ontime" | |
| 32 | + } | |
| 33 | + } | |
| 34 | + } | |
| 35 | + ] | |
| 36 | + }, | |
| 37 | + { | |
| 38 | + "MonitoredStopVisit": [ | |
| 39 | + { | |
| 40 | + "RecordedAtTime": "2026-01-15T08:29:34Z", | |
| 41 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | |
| 42 | + "MonitoredVehicleJourney": { | |
| 43 | + "LineRef": { "value": "IDFM:C1099" }, | |
| 44 | + "DirectionRef": { "value": "IDFM:1099D" }, | |
| 45 | + "DestinationRef": { "value": "IDFM:1099DST" }, | |
| 46 | + "MonitoredCall": { | |
| 47 | + "ExpectedDepartureTime": "2026-01-15T08:36:00Z", | |
| 48 | + "AimedDepartureTime": "2026-01-15T08:36:00Z", | |
| 49 | + "DepartureStatus": "ontime" | |
| 50 | + } | |
| 51 | + } | |
| 52 | + }, | |
| 53 | + { | |
| 54 | + "RecordedAtTime": "2026-01-15T08:29:33Z", | |
| 55 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | |
| 56 | + "MonitoredVehicleJourney": { | |
| 57 | + "LineRef": { "value": "IDFM:C1001" }, | |
| 58 | + "MonitoredCall": { | |
| 59 | + "ExpectedDepartureTime": "2026-01-15T08:42:00Z", | |
| 60 | + "DepartureStatus": "ontime" | |
| 61 | + } | |
| 62 | + } | |
| 63 | + } | |
| 64 | + ] | |
| 65 | + } | |
| 66 | + ] | |
| 67 | + } | |
| 68 | + } | |
| 69 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "StopMonitoringDelivery": [ | ||
| 5 | + { | ||
| 6 | + "MonitoredStopVisit": [ | ||
| 7 | + { | ||
| 8 | + "RecordedAtTime": "2026-01-15T08:29:36Z", | ||
| 9 | + "MonitoringRef": { "value": "IDFM:SP:1099" }, | ||
| 10 | + "MonitoredVehicleJourney": { | ||
| 11 | + "LineRef": { "value": "IDFM:C1001" }, | ||
| 12 | + "DirectionRef": { "value": "IDFM:1099D" }, | ||
| 13 | + "DestinationRef": { "value": "IDFM:1099DST" }, | ||
| 14 | + "MonitoredCall": { | ||
| 15 | + "ExpectedDepartureTime": "2026-01-15T08:31:00Z", | ||
| 16 | + "AimedDepartureTime": "2026-01-15T08:31:00Z", | ||
| 17 | + "DepartureStatus": "ontime" | ||
| 18 | + } | ||
| 19 | + } | ||
| 20 | + }, | ||
| 21 | + { | ||
| 22 | + "RecordedAtTime": "2026-01-15T08:29:35Z", | ||
| 23 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | ||
| 24 | + "MonitoredVehicleJourney": { | ||
| 25 | + "LineRef": { "value": "IDFM:C1001" }, | ||
| 26 | + "DirectionRef": { "value": "IDFM:1001D" }, | ||
| 27 | + "DestinationRef": { "value": "IDFM:1001DST" }, | ||
| 28 | + "MonitoredCall": { | ||
| 29 | + "ExpectedDepartureTime": "2026-01-15T08:34:00Z", | ||
| 30 | + "AimedDepartureTime": "2026-01-15T08:33:00Z", | ||
| 31 | + "DepartureStatus": "ontime" | ||
| 32 | + } | ||
| 33 | + } | ||
| 34 | + } | ||
| 35 | + ] | ||
| 36 | + }, | ||
| 37 | + { | ||
| 38 | + "MonitoredStopVisit": [ | ||
| 39 | + { | ||
| 40 | + "RecordedAtTime": "2026-01-15T08:29:34Z", | ||
| 41 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | ||
| 42 | + "MonitoredVehicleJourney": { | ||
| 43 | + "LineRef": { "value": "IDFM:C1099" }, | ||
| 44 | + "DirectionRef": { "value": "IDFM:1099D" }, | ||
| 45 | + "DestinationRef": { "value": "IDFM:1099DST" }, | ||
| 46 | + "MonitoredCall": { | ||
| 47 | + "ExpectedDepartureTime": "2026-01-15T08:36:00Z", | ||
| 48 | + "AimedDepartureTime": "2026-01-15T08:36:00Z", | ||
| 49 | + "DepartureStatus": "ontime" | ||
| 50 | + } | ||
| 51 | + } | ||
| 52 | + }, | ||
| 53 | + { | ||
| 54 | + "RecordedAtTime": "2026-01-15T08:29:33Z", | ||
| 55 | + "MonitoringRef": { "value": "IDFM:SP:1001" }, | ||
| 56 | + "MonitoredVehicleJourney": { | ||
| 57 | + "LineRef": { "value": "IDFM:C1001" }, | ||
| 58 | + "MonitoredCall": { | ||
| 59 | + "ExpectedDepartureTime": "2026-01-15T08:42:00Z", | ||
| 60 | + "DepartureStatus": "ontime" | ||
| 61 | + } | ||
| 62 | + } | ||
| 63 | + } | ||
| 64 | + ] | ||
| 65 | + } | ||
| 66 | + ] | ||
| 67 | + } | ||
| 68 | + } | ||
| 69 | +} | ||
added
fixtures/departures/prim/rer.json +42 -0 | new file mode 100644 | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:44Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C4001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:4001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:33:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:32:30Z", | |
| 19 | + "DepartureStatus": "ontime" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "RecordedAtTime": "2026-01-15T08:29:43Z", | |
| 25 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | |
| 26 | + "MonitoredVehicleJourney": { | |
| 27 | + "LineRef": { "value": "IDFM:C4001" }, | |
| 28 | + "DirectionRef": { "value": "IDFM:4001D" }, | |
| 29 | + "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 30 | + "MonitoredCall": { | |
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:47:00Z", | |
| 32 | + "AimedDepartureTime": "2026-01-15T08:45:00Z", | |
| 33 | + "DepartureStatus": "ontime" | |
| 34 | + } | |
| 35 | + } | |
| 36 | + } | |
| 37 | + ] | |
| 38 | + } | |
| 39 | + ] | |
| 40 | + } | |
| 41 | + } | |
| 42 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:44Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C4001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:4001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:4001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:33:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:32:30Z", | ||
| 19 | + "DepartureStatus": "ontime" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + }, | ||
| 23 | + { | ||
| 24 | + "RecordedAtTime": "2026-01-15T08:29:43Z", | ||
| 25 | + "MonitoringRef": { "value": "IDFM:SP:4001" }, | ||
| 26 | + "MonitoredVehicleJourney": { | ||
| 27 | + "LineRef": { "value": "IDFM:C4001" }, | ||
| 28 | + "DirectionRef": { "value": "IDFM:4001D" }, | ||
| 29 | + "DestinationRef": { "value": "IDFM:4001DST" }, | ||
| 30 | + "MonitoredCall": { | ||
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:47:00Z", | ||
| 32 | + "AimedDepartureTime": "2026-01-15T08:45:00Z", | ||
| 33 | + "DepartureStatus": "ontime" | ||
| 34 | + } | ||
| 35 | + } | ||
| 36 | + } | ||
| 37 | + ] | ||
| 38 | + } | ||
| 39 | + ] | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | +} | ||
added
fixtures/departures/prim/scheduled-only.json +38 -0 | new file mode 100644 | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:56Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:41Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C5001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:5001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:5001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "AimedDepartureTime": "2026-01-15T08:44:00Z" | |
| 18 | + } | |
| 19 | + } | |
| 20 | + }, | |
| 21 | + { | |
| 22 | + "RecordedAtTime": "2026-01-15T08:29:39Z", | |
| 23 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | |
| 24 | + "MonitoredVehicleJourney": { | |
| 25 | + "LineRef": { "value": "IDFM:C5001" }, | |
| 26 | + "DirectionRef": { "value": "IDFM:5001D" }, | |
| 27 | + "DestinationRef": { "value": "IDFM:5001DST" }, | |
| 28 | + "MonitoredCall": { | |
| 29 | + "AimedDepartureTime": "2026-01-15T08:58:00Z" | |
| 30 | + } | |
| 31 | + } | |
| 32 | + } | |
| 33 | + ] | |
| 34 | + } | |
| 35 | + ] | |
| 36 | + } | |
| 37 | + } | |
| 38 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:56Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:41Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C5001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:5001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:5001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "AimedDepartureTime": "2026-01-15T08:44:00Z" | ||
| 18 | + } | ||
| 19 | + } | ||
| 20 | + }, | ||
| 21 | + { | ||
| 22 | + "RecordedAtTime": "2026-01-15T08:29:39Z", | ||
| 23 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | ||
| 24 | + "MonitoredVehicleJourney": { | ||
| 25 | + "LineRef": { "value": "IDFM:C5001" }, | ||
| 26 | + "DirectionRef": { "value": "IDFM:5001D" }, | ||
| 27 | + "DestinationRef": { "value": "IDFM:5001DST" }, | ||
| 28 | + "MonitoredCall": { | ||
| 29 | + "AimedDepartureTime": "2026-01-15T08:58:00Z" | ||
| 30 | + } | ||
| 31 | + } | ||
| 32 | + } | ||
| 33 | + ] | ||
| 34 | + } | ||
| 35 | + ] | ||
| 36 | + } | ||
| 37 | + } | ||
| 38 | +} | ||
added
fixtures/departures/prim/tram.json +28 -0 | new file mode 100644 | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:47Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C3001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:3001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:3001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:36:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:35:00Z", | |
| 19 | + "DepartureStatus": "ontime" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + } | |
| 23 | + ] | |
| 24 | + } | |
| 25 | + ] | |
| 26 | + } | |
| 27 | + } | |
| 28 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:58Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:47Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:3001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C3001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:3001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:3001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:36:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:35:00Z", | ||
| 19 | + "DepartureStatus": "ontime" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + } | ||
| 23 | + ] | ||
| 24 | + } | ||
| 25 | + ] | ||
| 26 | + } | ||
| 27 | + } | ||
| 28 | +} | ||
added
fixtures/departures/prim/transilien.json +42 -0 | new file mode 100644 | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +{ | |
| 2 | + "Siri": { | |
| 3 | + "ServiceDelivery": { | |
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | |
| 5 | + "StopMonitoringDelivery": [ | |
| 6 | + { | |
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:56Z", | |
| 8 | + "MonitoredStopVisit": [ | |
| 9 | + { | |
| 10 | + "RecordedAtTime": "2026-01-15T08:29:41Z", | |
| 11 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | |
| 12 | + "MonitoredVehicleJourney": { | |
| 13 | + "LineRef": { "value": "IDFM:C5001" }, | |
| 14 | + "DirectionRef": { "value": "IDFM:5001D" }, | |
| 15 | + "DestinationRef": { "value": "IDFM:5001DST" }, | |
| 16 | + "MonitoredCall": { | |
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:41:00Z", | |
| 18 | + "AimedDepartureTime": "2026-01-15T08:40:00Z", | |
| 19 | + "DepartureStatus": "ontime" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "RecordedAtTime": "2026-01-15T08:29:39Z", | |
| 25 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | |
| 26 | + "MonitoredVehicleJourney": { | |
| 27 | + "LineRef": { "value": "IDFM:C5001" }, | |
| 28 | + "DirectionRef": { "value": "IDFM:5001D" }, | |
| 29 | + "DestinationRef": { "value": "IDFM:5001DST" }, | |
| 30 | + "MonitoredCall": { | |
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:55:00Z", | |
| 32 | + "AimedDepartureTime": "2026-01-15T08:54:00Z", | |
| 33 | + "DepartureStatus": "ontime" | |
| 34 | + } | |
| 35 | + } | |
| 36 | + } | |
| 37 | + ] | |
| 38 | + } | |
| 39 | + ] | |
| 40 | + } | |
| 41 | + } | |
| 42 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | +{ | ||
| 2 | + "Siri": { | ||
| 3 | + "ServiceDelivery": { | ||
| 4 | + "ResponseTimestamp": "2026-01-15T08:30:00Z", | ||
| 5 | + "StopMonitoringDelivery": [ | ||
| 6 | + { | ||
| 7 | + "ResponseTimestamp": "2026-01-15T08:29:56Z", | ||
| 8 | + "MonitoredStopVisit": [ | ||
| 9 | + { | ||
| 10 | + "RecordedAtTime": "2026-01-15T08:29:41Z", | ||
| 11 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | ||
| 12 | + "MonitoredVehicleJourney": { | ||
| 13 | + "LineRef": { "value": "IDFM:C5001" }, | ||
| 14 | + "DirectionRef": { "value": "IDFM:5001D" }, | ||
| 15 | + "DestinationRef": { "value": "IDFM:5001DST" }, | ||
| 16 | + "MonitoredCall": { | ||
| 17 | + "ExpectedDepartureTime": "2026-01-15T08:41:00Z", | ||
| 18 | + "AimedDepartureTime": "2026-01-15T08:40:00Z", | ||
| 19 | + "DepartureStatus": "ontime" | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + }, | ||
| 23 | + { | ||
| 24 | + "RecordedAtTime": "2026-01-15T08:29:39Z", | ||
| 25 | + "MonitoringRef": { "value": "IDFM:SP:5001" }, | ||
| 26 | + "MonitoredVehicleJourney": { | ||
| 27 | + "LineRef": { "value": "IDFM:C5001" }, | ||
| 28 | + "DirectionRef": { "value": "IDFM:5001D" }, | ||
| 29 | + "DestinationRef": { "value": "IDFM:5001DST" }, | ||
| 30 | + "MonitoredCall": { | ||
| 31 | + "ExpectedDepartureTime": "2026-01-15T08:55:00Z", | ||
| 32 | + "AimedDepartureTime": "2026-01-15T08:54:00Z", | ||
| 33 | + "DepartureStatus": "ontime" | ||
| 34 | + } | ||
| 35 | + } | ||
| 36 | + } | ||
| 37 | + ] | ||
| 38 | + } | ||
| 39 | + ] | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | +} | ||
added
packages/backend/src/departure-endpoint.ts +501 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/backend/src/departure-service.ts +227 -0 | new file mode 100644 | ||
| @@ -0,0 +1,227 @@ | ||
| 1 | +import { | |
| 2 | + SCHEMA_VERSION, | |
| 3 | + type ErrorCode, | |
| 4 | +} from "../../contracts/src/index.ts"; | |
| 5 | + | |
| 6 | +import type { CatalogReader } from "./catalog.ts"; | |
| 7 | +import { | |
| 8 | + DEPARTURE_METRIC_NAME, | |
| 9 | + type DepartureMetricName, | |
| 10 | + type DepartureMetrics, | |
| 11 | + type DepartureResolver, | |
| 12 | + type PublicDepartureData, | |
| 13 | + type PublicDepartureError, | |
| 14 | +} from "./departures.ts"; | |
| 15 | +import { | |
| 16 | + buildPrimStopMonitoringUrl, | |
| 17 | + normalizePrimDepartureResponse, | |
| 18 | + parseRetryAfterSeconds, | |
| 19 | +} from "./prim-departures.ts"; | |
| 20 | + | |
| 21 | +export interface PrimDepartureRelayRequest { | |
| 22 | + readonly target: URL; | |
| 23 | + readonly apiKey: string; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export interface PrimDepartureRelayResponse { | |
| 27 | + readonly status: number; | |
| 28 | + readonly bytes: Uint8Array; | |
| 29 | + readonly retryAfter?: string; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export type PrimDepartureRelay = ( | |
| 33 | + request: PrimDepartureRelayRequest, | |
| 34 | +) => Promise<PrimDepartureRelayResponse>; | |
| 35 | + | |
| 36 | +export interface UpstreamLimiter { | |
| 37 | + run<T>(operation: () => Promise<T>): Promise<T>; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export interface PrimDepartureResolverDependencies { | |
| 41 | + readonly catalog: CatalogReader; | |
| 42 | + readonly relay: PrimDepartureRelay; | |
| 43 | + readonly clock?: () => number; | |
| 44 | + readonly metrics?: DepartureMetrics; | |
| 45 | + readonly limiter?: UpstreamLimiter; | |
| 46 | +} | |
| 47 | + | |
| 48 | +export const MAX_UPSTREAM_CALLS = 8 as const; | |
| 49 | +const UINT32_MAX = 0xffff_ffff; | |
| 50 | +const STRICT_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); | |
| 51 | + | |
| 52 | +export function createUpstreamLimiter( | |
| 53 | + maxActive = MAX_UPSTREAM_CALLS, | |
| 54 | +): UpstreamLimiter { | |
| 55 | + if (!Number.isInteger(maxActive) || maxActive < 1) { | |
| 56 | + throw new RangeError("maxActive must be a positive integer"); | |
| 57 | + } | |
| 58 | + | |
| 59 | + let active = 0; | |
| 60 | + const queue: Array<() => void> = []; | |
| 61 | + | |
| 62 | + async function acquire(): Promise<void> { | |
| 63 | + if (active < maxActive) { | |
| 64 | + active += 1; | |
| 65 | + return; | |
| 66 | + } | |
| 67 | + | |
| 68 | + await new Promise<void>((resolve) => { | |
| 69 | + queue.push(() => { | |
| 70 | + active += 1; | |
| 71 | + resolve(); | |
| 72 | + }); | |
| 73 | + }); | |
| 74 | + } | |
| 75 | + | |
| 76 | + function release(): void { | |
| 77 | + active -= 1; | |
| 78 | + queue.shift()?.(); | |
| 79 | + } | |
| 80 | + | |
| 81 | + return Object.freeze({ | |
| 82 | + async run<T>(operation: () => Promise<T>): Promise<T> { | |
| 83 | + await acquire(); | |
| 84 | + try { | |
| 85 | + return await operation(); | |
| 86 | + } finally { | |
| 87 | + release(); | |
| 88 | + } | |
| 89 | + }, | |
| 90 | + }); | |
| 91 | +} | |
| 92 | + | |
| 93 | +const PROCESS_UPSTREAM_LIMITER = createUpstreamLimiter(); | |
| 94 | + | |
| 95 | +function nowMilliseconds(clock: (() => number) | undefined): number { | |
| 96 | + try { | |
| 97 | + const value = clock?.() ?? Date.now(); | |
| 98 | + return Number.isFinite(value) && value >= 0 ? value : Date.now(); | |
| 99 | + } catch { | |
| 100 | + return Date.now(); | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +function metric( | |
| 105 | + metrics: DepartureMetrics | undefined, | |
| 106 | + name: DepartureMetricName, | |
| 107 | + value: number, | |
| 108 | +): void { | |
| 109 | + if (!Number.isFinite(value) || value < 0) return; | |
| 110 | + try { | |
| 111 | + metrics?.record(name, value); | |
| 112 | + } catch { | |
| 113 | + // Instrumentation must never affect the relay or request settlement. | |
| 114 | + } | |
| 115 | +} | |
| 116 | + | |
| 117 | +function epochSeconds(milliseconds: number): number { | |
| 118 | + return Math.min(UINT32_MAX, Math.floor(milliseconds / 1_000)); | |
| 119 | +} | |
| 120 | + | |
| 121 | +function publicError( | |
| 122 | + code: ErrorCode, | |
| 123 | + milliseconds: number, | |
| 124 | + retryAfterSeconds?: number, | |
| 125 | +): PublicDepartureError { | |
| 126 | + return { | |
| 127 | + schemaVersion: SCHEMA_VERSION, | |
| 128 | + code, | |
| 129 | + occurredAt: epochSeconds(milliseconds), | |
| 130 | + ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), | |
| 131 | + }; | |
| 132 | +} | |
| 133 | + | |
| 134 | +interface RelayResponseEnvelope { | |
| 135 | + readonly status: number; | |
| 136 | + readonly bytes?: unknown; | |
| 137 | + readonly retryAfter?: unknown; | |
| 138 | +} | |
| 139 | + | |
| 140 | +function validRelayResponse(response: unknown): response is RelayResponseEnvelope { | |
| 141 | + if (typeof response !== "object" || response === null || !("status" in response)) { | |
| 142 | + return false; | |
| 143 | + } | |
| 144 | + const status = response.status; | |
| 145 | + return typeof status === "number" | |
| 146 | + && Number.isInteger(status) | |
| 147 | + && status >= 100 | |
| 148 | + && status <= 599; | |
| 149 | +} | |
| 150 | + | |
| 151 | +export function createPrimDepartureResolver( | |
| 152 | + dependencies: PrimDepartureResolverDependencies, | |
| 153 | +): DepartureResolver { | |
| 154 | + const limiter = dependencies.limiter ?? PROCESS_UPSTREAM_LIMITER; | |
| 155 | + | |
| 156 | + return async (request) => { | |
| 157 | + const resolution = dependencies.catalog.resolveService(request.serviceId); | |
| 158 | + if (resolution.status !== "RESOLVED") { | |
| 159 | + return publicError("INVALID_SERVICE", nowMilliseconds(dependencies.clock)); | |
| 160 | + } | |
| 161 | + | |
| 162 | + const target = buildPrimStopMonitoringUrl(resolution); | |
| 163 | + let apiKey = request.apiKey; | |
| 164 | + let response: unknown; | |
| 165 | + let completedAtMilliseconds: number | undefined; | |
| 166 | + try { | |
| 167 | + response = await limiter.run(async () => { | |
| 168 | + metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST, 1); | |
| 169 | + const startedAtMilliseconds = nowMilliseconds(dependencies.clock); | |
| 170 | + let status = 0; | |
| 171 | + try { | |
| 172 | + const relayResponse = await dependencies.relay({ target, apiKey }); | |
| 173 | + status = validRelayResponse(relayResponse) ? relayResponse.status : 0; | |
| 174 | + return relayResponse; | |
| 175 | + } finally { | |
| 176 | + completedAtMilliseconds = nowMilliseconds(dependencies.clock); | |
| 177 | + metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_STATUS, status); | |
| 178 | + metric( | |
| 179 | + dependencies.metrics, | |
| 180 | + DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS, | |
| 181 | + Math.max(0, completedAtMilliseconds - startedAtMilliseconds), | |
| 182 | + ); | |
| 183 | + } | |
| 184 | + }); | |
| 185 | + } catch { | |
| 186 | + return publicError( | |
| 187 | + "SOURCE_UNAVAILABLE", | |
| 188 | + completedAtMilliseconds ?? nowMilliseconds(dependencies.clock), | |
| 189 | + ); | |
| 190 | + } finally { | |
| 191 | + apiKey = ""; | |
| 192 | + } | |
| 193 | + | |
| 194 | + const receivedAtMilliseconds = | |
| 195 | + completedAtMilliseconds ?? nowMilliseconds(dependencies.clock); | |
| 196 | + if (!validRelayResponse(response)) { | |
| 197 | + return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | |
| 198 | + } | |
| 199 | + if (response.status === 401 || response.status === 403) { | |
| 200 | + return publicError("API_KEY_INVALID", receivedAtMilliseconds); | |
| 201 | + } | |
| 202 | + if (response.status === 429) { | |
| 203 | + return publicError( | |
| 204 | + "RATE_LIMITED", | |
| 205 | + receivedAtMilliseconds, | |
| 206 | + parseRetryAfterSeconds(response.retryAfter), | |
| 207 | + ); | |
| 208 | + } | |
| 209 | + if (response.status < 200 || response.status >= 300) { | |
| 210 | + return publicError("SOURCE_UNAVAILABLE", receivedAtMilliseconds); | |
| 211 | + } | |
| 212 | + if (!(response.bytes instanceof Uint8Array)) { | |
| 213 | + return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | |
| 214 | + } | |
| 215 | + | |
| 216 | + try { | |
| 217 | + const payload = JSON.parse(STRICT_UTF8_DECODER.decode(response.bytes)) as unknown; | |
| 218 | + return normalizePrimDepartureResponse( | |
| 219 | + payload, | |
| 220 | + resolution, | |
| 221 | + epochSeconds(receivedAtMilliseconds), | |
| 222 | + ) as PublicDepartureData; | |
| 223 | + } catch { | |
| 224 | + return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | |
| 225 | + } | |
| 226 | + }; | |
| 227 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,227 @@ | |||
| 1 | +import { | ||
| 2 | + SCHEMA_VERSION, | ||
| 3 | + type ErrorCode, | ||
| 4 | +} from "../../contracts/src/index.ts"; | ||
| 5 | + | ||
| 6 | +import type { CatalogReader } from "./catalog.ts"; | ||
| 7 | +import { | ||
| 8 | + DEPARTURE_METRIC_NAME, | ||
| 9 | + type DepartureMetricName, | ||
| 10 | + type DepartureMetrics, | ||
| 11 | + type DepartureResolver, | ||
| 12 | + type PublicDepartureData, | ||
| 13 | + type PublicDepartureError, | ||
| 14 | +} from "./departures.ts"; | ||
| 15 | +import { | ||
| 16 | + buildPrimStopMonitoringUrl, | ||
| 17 | + normalizePrimDepartureResponse, | ||
| 18 | + parseRetryAfterSeconds, | ||
| 19 | +} from "./prim-departures.ts"; | ||
| 20 | + | ||
| 21 | +export interface PrimDepartureRelayRequest { | ||
| 22 | + readonly target: URL; | ||
| 23 | + readonly apiKey: string; | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +export interface PrimDepartureRelayResponse { | ||
| 27 | + readonly status: number; | ||
| 28 | + readonly bytes: Uint8Array; | ||
| 29 | + readonly retryAfter?: string; | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +export type PrimDepartureRelay = ( | ||
| 33 | + request: PrimDepartureRelayRequest, | ||
| 34 | +) => Promise<PrimDepartureRelayResponse>; | ||
| 35 | + | ||
| 36 | +export interface UpstreamLimiter { | ||
| 37 | + run<T>(operation: () => Promise<T>): Promise<T>; | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +export interface PrimDepartureResolverDependencies { | ||
| 41 | + readonly catalog: CatalogReader; | ||
| 42 | + readonly relay: PrimDepartureRelay; | ||
| 43 | + readonly clock?: () => number; | ||
| 44 | + readonly metrics?: DepartureMetrics; | ||
| 45 | + readonly limiter?: UpstreamLimiter; | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +export const MAX_UPSTREAM_CALLS = 8 as const; | ||
| 49 | +const UINT32_MAX = 0xffff_ffff; | ||
| 50 | +const STRICT_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); | ||
| 51 | + | ||
| 52 | +export function createUpstreamLimiter( | ||
| 53 | + maxActive = MAX_UPSTREAM_CALLS, | ||
| 54 | +): UpstreamLimiter { | ||
| 55 | + if (!Number.isInteger(maxActive) || maxActive < 1) { | ||
| 56 | + throw new RangeError("maxActive must be a positive integer"); | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + let active = 0; | ||
| 60 | + const queue: Array<() => void> = []; | ||
| 61 | + | ||
| 62 | + async function acquire(): Promise<void> { | ||
| 63 | + if (active < maxActive) { | ||
| 64 | + active += 1; | ||
| 65 | + return; | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + await new Promise<void>((resolve) => { | ||
| 69 | + queue.push(() => { | ||
| 70 | + active += 1; | ||
| 71 | + resolve(); | ||
| 72 | + }); | ||
| 73 | + }); | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + function release(): void { | ||
| 77 | + active -= 1; | ||
| 78 | + queue.shift()?.(); | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + return Object.freeze({ | ||
| 82 | + async run<T>(operation: () => Promise<T>): Promise<T> { | ||
| 83 | + await acquire(); | ||
| 84 | + try { | ||
| 85 | + return await operation(); | ||
| 86 | + } finally { | ||
| 87 | + release(); | ||
| 88 | + } | ||
| 89 | + }, | ||
| 90 | + }); | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +const PROCESS_UPSTREAM_LIMITER = createUpstreamLimiter(); | ||
| 94 | + | ||
| 95 | +function nowMilliseconds(clock: (() => number) | undefined): number { | ||
| 96 | + try { | ||
| 97 | + const value = clock?.() ?? Date.now(); | ||
| 98 | + return Number.isFinite(value) && value >= 0 ? value : Date.now(); | ||
| 99 | + } catch { | ||
| 100 | + return Date.now(); | ||
| 101 | + } | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +function metric( | ||
| 105 | + metrics: DepartureMetrics | undefined, | ||
| 106 | + name: DepartureMetricName, | ||
| 107 | + value: number, | ||
| 108 | +): void { | ||
| 109 | + if (!Number.isFinite(value) || value < 0) return; | ||
| 110 | + try { | ||
| 111 | + metrics?.record(name, value); | ||
| 112 | + } catch { | ||
| 113 | + // Instrumentation must never affect the relay or request settlement. | ||
| 114 | + } | ||
| 115 | +} | ||
| 116 | + | ||
| 117 | +function epochSeconds(milliseconds: number): number { | ||
| 118 | + return Math.min(UINT32_MAX, Math.floor(milliseconds / 1_000)); | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +function publicError( | ||
| 122 | + code: ErrorCode, | ||
| 123 | + milliseconds: number, | ||
| 124 | + retryAfterSeconds?: number, | ||
| 125 | +): PublicDepartureError { | ||
| 126 | + return { | ||
| 127 | + schemaVersion: SCHEMA_VERSION, | ||
| 128 | + code, | ||
| 129 | + occurredAt: epochSeconds(milliseconds), | ||
| 130 | + ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), | ||
| 131 | + }; | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +interface RelayResponseEnvelope { | ||
| 135 | + readonly status: number; | ||
| 136 | + readonly bytes?: unknown; | ||
| 137 | + readonly retryAfter?: unknown; | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +function validRelayResponse(response: unknown): response is RelayResponseEnvelope { | ||
| 141 | + if (typeof response !== "object" || response === null || !("status" in response)) { | ||
| 142 | + return false; | ||
| 143 | + } | ||
| 144 | + const status = response.status; | ||
| 145 | + return typeof status === "number" | ||
| 146 | + && Number.isInteger(status) | ||
| 147 | + && status >= 100 | ||
| 148 | + && status <= 599; | ||
| 149 | +} | ||
| 150 | + | ||
| 151 | +export function createPrimDepartureResolver( | ||
| 152 | + dependencies: PrimDepartureResolverDependencies, | ||
| 153 | +): DepartureResolver { | ||
| 154 | + const limiter = dependencies.limiter ?? PROCESS_UPSTREAM_LIMITER; | ||
| 155 | + | ||
| 156 | + return async (request) => { | ||
| 157 | + const resolution = dependencies.catalog.resolveService(request.serviceId); | ||
| 158 | + if (resolution.status !== "RESOLVED") { | ||
| 159 | + return publicError("INVALID_SERVICE", nowMilliseconds(dependencies.clock)); | ||
| 160 | + } | ||
| 161 | + | ||
| 162 | + const target = buildPrimStopMonitoringUrl(resolution); | ||
| 163 | + let apiKey = request.apiKey; | ||
| 164 | + let response: unknown; | ||
| 165 | + let completedAtMilliseconds: number | undefined; | ||
| 166 | + try { | ||
| 167 | + response = await limiter.run(async () => { | ||
| 168 | + metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST, 1); | ||
| 169 | + const startedAtMilliseconds = nowMilliseconds(dependencies.clock); | ||
| 170 | + let status = 0; | ||
| 171 | + try { | ||
| 172 | + const relayResponse = await dependencies.relay({ target, apiKey }); | ||
| 173 | + status = validRelayResponse(relayResponse) ? relayResponse.status : 0; | ||
| 174 | + return relayResponse; | ||
| 175 | + } finally { | ||
| 176 | + completedAtMilliseconds = nowMilliseconds(dependencies.clock); | ||
| 177 | + metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_STATUS, status); | ||
| 178 | + metric( | ||
| 179 | + dependencies.metrics, | ||
| 180 | + DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS, | ||
| 181 | + Math.max(0, completedAtMilliseconds - startedAtMilliseconds), | ||
| 182 | + ); | ||
| 183 | + } | ||
| 184 | + }); | ||
| 185 | + } catch { | ||
| 186 | + return publicError( | ||
| 187 | + "SOURCE_UNAVAILABLE", | ||
| 188 | + completedAtMilliseconds ?? nowMilliseconds(dependencies.clock), | ||
| 189 | + ); | ||
| 190 | + } finally { | ||
| 191 | + apiKey = ""; | ||
| 192 | + } | ||
| 193 | + | ||
| 194 | + const receivedAtMilliseconds = | ||
| 195 | + completedAtMilliseconds ?? nowMilliseconds(dependencies.clock); | ||
| 196 | + if (!validRelayResponse(response)) { | ||
| 197 | + return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | ||
| 198 | + } | ||
| 199 | + if (response.status === 401 || response.status === 403) { | ||
| 200 | + return publicError("API_KEY_INVALID", receivedAtMilliseconds); | ||
| 201 | + } | ||
| 202 | + if (response.status === 429) { | ||
| 203 | + return publicError( | ||
| 204 | + "RATE_LIMITED", | ||
| 205 | + receivedAtMilliseconds, | ||
| 206 | + parseRetryAfterSeconds(response.retryAfter), | ||
| 207 | + ); | ||
| 208 | + } | ||
| 209 | + if (response.status < 200 || response.status >= 300) { | ||
| 210 | + return publicError("SOURCE_UNAVAILABLE", receivedAtMilliseconds); | ||
| 211 | + } | ||
| 212 | + if (!(response.bytes instanceof Uint8Array)) { | ||
| 213 | + return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | ||
| 214 | + } | ||
| 215 | + | ||
| 216 | + try { | ||
| 217 | + const payload = JSON.parse(STRICT_UTF8_DECODER.decode(response.bytes)) as unknown; | ||
| 218 | + return normalizePrimDepartureResponse( | ||
| 219 | + payload, | ||
| 220 | + resolution, | ||
| 221 | + epochSeconds(receivedAtMilliseconds), | ||
| 222 | + ) as PublicDepartureData; | ||
| 223 | + } catch { | ||
| 224 | + return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | ||
| 225 | + } | ||
| 226 | + }; | ||
| 227 | +} | ||
added
packages/backend/src/departures.ts +695 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/backend/src/index.ts +34 -317 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/backend/src/prim-departures.ts +258 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/backend/test/backend.test.ts +10 -2 | @@ -141,8 +141,16 @@ test("relay sends a GET to the canonical origin with the key only in apikey", as | ||
| 141 | 141 | |
| 142 | 142 | assert.equal(capturedTarget?.origin, PRIM_ORIGIN); |
| 143 | 143 | assert.equal(capturedTarget?.href.includes(TEST_KEY), false); |
| 144 | - assert.deepEqual(capturedInit?.headers, { apikey: TEST_KEY }); | |
| 145 | - assert.deepEqual(Object.keys(capturedInit?.headers ?? {}), ["apikey"]); | |
| 144 | + assert.deepEqual(capturedInit?.headers, { | |
| 145 | + apikey: TEST_KEY, | |
| 146 | + accept: "application/json", | |
| 147 | + "accept-encoding": "gzip", | |
| 148 | + }); | |
| 149 | + assert.deepEqual(Object.keys(capturedInit?.headers ?? {}), [ | |
| 150 | + "apikey", | |
| 151 | + "accept", | |
| 152 | + "accept-encoding", | |
| 153 | + ]); | |
| 146 | 154 | assert.equal(capturedInit?.method, "GET"); |
| 147 | 155 | assert.equal(capturedInit?.redirect, "manual"); |
| 148 | 156 | assert.equal(capturedInit?.credentials, "omit"); |
| @@ -141,8 +141,16 @@ test("relay sends a GET to the canonical origin with the key only in apikey", as | |||
| 141 | 141 | ||
| 142 | assert.equal(capturedTarget?.origin, PRIM_ORIGIN); | 142 | assert.equal(capturedTarget?.origin, PRIM_ORIGIN); |
| 143 | assert.equal(capturedTarget?.href.includes(TEST_KEY), false); | 143 | assert.equal(capturedTarget?.href.includes(TEST_KEY), false); |
| 144 | - assert.deepEqual(capturedInit?.headers, { apikey: TEST_KEY }); | 144 | + assert.deepEqual(capturedInit?.headers, { |
| 145 | - assert.deepEqual(Object.keys(capturedInit?.headers ?? {}), ["apikey"]); | 145 | + apikey: TEST_KEY, |
| 146 | + accept: "application/json", | ||
| 147 | + "accept-encoding": "gzip", | ||
| 148 | + }); | ||
| 149 | + assert.deepEqual(Object.keys(capturedInit?.headers ?? {}), [ | ||
| 150 | + "apikey", | ||
| 151 | + "accept", | ||
| 152 | + "accept-encoding", | ||
| 153 | + ]); | ||
| 146 | assert.equal(capturedInit?.method, "GET"); | 154 | assert.equal(capturedInit?.method, "GET"); |
| 147 | assert.equal(capturedInit?.redirect, "manual"); | 155 | assert.equal(capturedInit?.redirect, "manual"); |
| 148 | assert.equal(capturedInit?.credentials, "omit"); | 156 | assert.equal(capturedInit?.credentials, "omit"); |
added
packages/backend/test/departure-endpoint.test.ts +810 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/backend/test/departures.test.ts +1442 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
scripts/test.mjs +2 -0 | @@ -10,6 +10,8 @@ run(process.execPath, [ | ||
| 10 | 10 | "--test", |
| 11 | 11 | "packages/contracts/test/contracts.test.ts", |
| 12 | 12 | "packages/backend/test/backend.test.ts", |
| 13 | + "packages/backend/test/departures.test.ts", | |
| 14 | + "packages/backend/test/departure-endpoint.test.ts", | |
| 13 | 15 | "packages/backend/test/catalog.test.ts", |
| 14 | 16 | "packages/backend/test/catalog-import.test.ts", |
| 15 | 17 | "packages/backend/test/catalog-http.test.ts", |
| @@ -10,6 +10,8 @@ run(process.execPath, [ | |||
| 10 | "--test", | 10 | "--test", |
| 11 | "packages/contracts/test/contracts.test.ts", | 11 | "packages/contracts/test/contracts.test.ts", |
| 12 | "packages/backend/test/backend.test.ts", | 12 | "packages/backend/test/backend.test.ts", |
| 13 | + "packages/backend/test/departures.test.ts", | ||
| 14 | + "packages/backend/test/departure-endpoint.test.ts", | ||
| 13 | "packages/backend/test/catalog.test.ts", | 15 | "packages/backend/test/catalog.test.ts", |
| 14 | "packages/backend/test/catalog-import.test.ts", | 16 | "packages/backend/test/catalog-import.test.ts", |
| 15 | "packages/backend/test/catalog-http.test.ts", | 17 | "packages/backend/test/catalog-http.test.ts", |