Skip to content

Commit 7481aca

Browse files
Merge pull request #100 from OneBusAway/feed-trip-descriptor-server
feat: publish route_id and start_date in the driver-reported TripDescriptor
2 parents 13c54cc + 854ee33 commit 7481aca

13 files changed

Lines changed: 1238 additions & 82 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ jobs:
2525
- name: Test
2626
run: go test ./...
2727

28+
- name: Build Docker image
29+
run: docker build -t vehicle-positions:ci .
30+
2831
- name: Verify committed Tailwind CSS is current
2932
if: runner.os == 'Linux'
3033
run: |

ARCHITECTURE.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ When a driver application reports a vehicle position, the following sequence occ
272272

273273
1. Consumer sends `GET /gtfs-rt/vehicle-positions` (optionally `?format=json`).
274274
2. Handler calls `tracker.ActiveVehicles()`, which reads the in-memory map and returns only vehicles whose `UpdatedAt` is newer than `time.Now().Add(-maxAge)`.
275-
3. `buildFeed()` constructs a `gtfs.FeedMessage` with a `FULL_DATASET` header and one `VehiclePosition` entity per active vehicle. `TripDescriptor` is omitted when `trip_id` is empty.
275+
3. `buildFeed()` constructs a `gtfs.FeedMessage` with a `FULL_DATASET` header and one `VehiclePosition` entity per active vehicle. `TripDescriptor` carries `trip_id`, `route_id` and `start_date` as sent, and is omitted when both `trip_id` and `route_id` are empty.
276276
4. Response is serialized with `proto.Marshal` (protobuf, default) or `protojson.Marshal` (`?format=json`) and written with the appropriate `Content-Type`.
277277

278278
### 5.3 Sequence Diagrams
@@ -361,7 +361,9 @@ Authentication is per endpoint, not global:
361361
```json
362362
{
363363
"vehicle_id": "bus-42",
364-
"trip_id": "route-5",
364+
"trip_id": "t_5_0830",
365+
"route_id": "5",
366+
"start_date": "20260715",
365367
"latitude": -1.2921,
366368
"longitude": 36.8219,
367369
"bearing": 90.0,
@@ -374,7 +376,9 @@ Authentication is per endpoint, not global:
374376
| Field | Required | Description |
375377
|-------|----------|-------------|
376378
| `vehicle_id` || Non-empty string identifier for the vehicle. |
377-
| `trip_id` || Optional trip identifier; empty string is allowed and results in no `TripDescriptor` in the feed. |
379+
| `trip_id` || GTFS `trip_id`. Empty when the driver only knows the route; never a route id. Max 100 characters. |
380+
| `route_id` || GTFS `route_id`. Max 100 characters. |
381+
| `start_date` || Service date `YYYYMMDD`; accepted only with `trip_id` or `route_id`. |
378382
| `latitude` || Decimal degrees, range `-90` to `90`. Cannot be `0` when `longitude` is also `0`. |
379383
| `longitude` || Decimal degrees, range `-180` to `180`. |
380384
| `bearing` || Direction of travel in degrees. |
@@ -389,6 +393,11 @@ Authentication is per endpoint, not global:
389393
- `latitude` must be in the range `-90` to `90`. Error: `latitude must be between -90 and 90`.
390394
- `longitude` must be in the range `-180` to `180`. Error: `longitude must be between -180 and 180`.
391395
- `timestamp` must be positive. Error: `timestamp must be positive`.
396+
- `trip_id` is capped at 100 characters. Error: `trip_id must be at most 100 characters`.
397+
- `route_id` is capped at 100 characters. Error: `route_id must be at most 100 characters`.
398+
- `start_date` must match `YYYYMMDD`. Error: `start_date must be YYYYMMDD`.
399+
- `start_date` must be a real calendar date. Error: `start_date must be a valid YYYYMMDD date`.
400+
- `start_date` requires `trip_id` or `route_id` to be non-empty. Error: `start_date requires trip_id or route_id`.
392401

393402
**Decoding behavior:**
394403

@@ -408,7 +417,7 @@ The feed is generated on every request entirely from the in-memory Tracker — t
408417
- **Header:** GTFS-Realtime version `2.0`, incrementality `FULL_DATASET`, current Unix timestamp.
409418
- One `FeedEntity` per active vehicle (`id = vehicle_id`).
410419
- Each entity carries a `VehiclePosition` with `Position` (latitude, longitude, bearing, speed), a `VehicleDescriptor` (`id` only), and an epoch `Timestamp` copied from the incoming report.
411-
- A `TripDescriptor` (`trip_id`) only when `trip_id` is non-empty.
420+
- A `TripDescriptor` with `trip_id`, `route_id` and `start_date` as reported, only when `trip_id` or `route_id` is non-empty.
412421

413422
Consumers can request JSON encoding by appending `?format=json` to the URL. The default response is binary protobuf (`application/x-protobuf`). Any other `format` value falls back to protobuf because only the exact string `json` is checked.
414423

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ RUN go mod download
55
COPY migrations ./migrations
66
COPY db ./db
77
COPY web ./web
8+
COPY rider ./rider
89
COPY *.go ./
910
RUN CGO_ENABLED=0 go build -o /vehicle-positions .
1011

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ The server produces a standard `FeedMessage` containing `VehiclePosition` entiti
201201
```protobuf
202202
vehicle {
203203
trip {
204-
trip_id: "route_5_0830"
204+
trip_id: "t_5_0830"
205205
route_id: "5"
206206
start_time: "08:30:00"
207207
start_date: "20260715"
@@ -248,7 +248,9 @@ Each location report is a single point sent directly from the Android app as it
248248
```json
249249
{
250250
"vehicle_id": "vehicle-042",
251-
"trip_id": "route_5_0830",
251+
"trip_id": "t_5_0830",
252+
"route_id": "5",
253+
"start_date": "20260715",
252254
"latitude": -1.2921,
253255
"longitude": 36.8219,
254256
"bearing": 180.0,
@@ -260,6 +262,8 @@ Each location report is a single point sent directly from the Android app as it
260262

261263
The server updates its in-memory state with the latest position and persists the point to the database. Points older than a configurable staleness threshold (default 5 minutes) are excluded from the GTFS-RT feed.
262264

265+
> `trip_id`, `route_id` and `start_date` are all optional. `trip_id` is the GTFS `trip_id` and must be left empty when the driver only knows the route — never send a route id in `trip_id`. `route_id` is the GTFS `route_id`; when `trip_id` is empty it is the only thing a consumer can match on. `start_date` is the service date, `YYYYMMDD`, and is accepted only alongside `trip_id` or `route_id`. The feed's `TripDescriptor` carries exactly the fields that were sent, and is omitted entirely when both `trip_id` and `route_id` are empty.
266+
263267
**`POST /api/v1/locations` validation and error contract**
264268

265269
The ingest endpoint performs strict request validation before writing data:
@@ -268,6 +272,7 @@ The ingest endpoint performs strict request validation before writing data:
268272
- The request body must contain exactly one JSON object.
269273
- Unknown JSON fields are rejected.
270274
- Standard payload validation still applies (`vehicle_id`, coordinates, timestamp).
275+
- `trip_id` and `route_id` are capped at 100 characters; `start_date` must be a real `YYYYMMDD` date.
271276

272277
Response codes:
273278

@@ -281,7 +286,7 @@ Examples:
281286
# Valid request
282287
curl -i -X POST http://localhost:8080/api/v1/locations \
283288
-H "Content-Type: application/json" \
284-
-d '{"vehicle_id":"bus-1","trip_id":"route-5","latitude":-1.29,"longitude":36.82,"timestamp":1752566400}'
289+
-d '{"vehicle_id":"bus-1","route_id":"5","latitude":-1.29,"longitude":36.82,"timestamp":1752566400}'
285290

286291
# Invalid content type -> 415
287292
curl -i -X POST http://localhost:8080/api/v1/locations \

cmd/simulator/main.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818

1919
type locationReport struct {
2020
VehicleID string `json:"vehicle_id"`
21+
RouteID string `json:"route_id"`
2122
Latitude float64 `json:"latitude"`
2223
Longitude float64 `json:"longitude"`
2324
Bearing float64 `json:"bearing"`
@@ -64,9 +65,10 @@ func main() {
6465
wg.Add(1)
6566
vehicleID := fmt.Sprintf("sim-vehicle-%03d", i+1)
6667
route := routes[i%len(routes)]
68+
routeID := fmt.Sprintf("sim-route-%d", i%len(routes)+1)
6769
go func() {
6870
defer wg.Done()
69-
simulateVehicle(ctx, client, *baseURL, vehicleID, route, *interval, s)
71+
simulateVehicle(ctx, client, *baseURL, vehicleID, route, routeID, *interval, s)
7072
}()
7173
}
7274
wg.Wait()
@@ -80,7 +82,7 @@ func main() {
8082
log.Printf("simulation complete: %d requests, %d ok, %d failed, avg=%dms", ok+fail, ok, fail, avgMS)
8183
}
8284

83-
func simulateVehicle(ctx context.Context, client *http.Client, baseURL, vehicleID string, route []Waypoint, interval time.Duration, s *stats) {
85+
func simulateVehicle(ctx context.Context, client *http.Client, baseURL, vehicleID string, route []Waypoint, routeID string, interval time.Duration, s *stats) {
8486
ticker := time.NewTicker(interval)
8587
defer ticker.Stop()
8688

@@ -122,6 +124,7 @@ func simulateVehicle(ctx context.Context, client *http.Client, baseURL, vehicleI
122124

123125
report := locationReport{
124126
VehicleID: vehicleID,
127+
RouteID: routeID,
125128
Latitude: pos.Lat,
126129
Longitude: pos.Lon,
127130
Bearing: brng,

cmd/simulator/main_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ func TestInterpolate(t *testing.T) {
151151
func TestLocationReportJSONRoundTrip(t *testing.T) {
152152
report := locationReport{
153153
VehicleID: "sim-vehicle-001",
154+
RouteID: "sim-route-1",
154155
Latitude: -1.2864,
155156
Longitude: 36.8172,
156157
Bearing: 327.5,
@@ -166,7 +167,7 @@ func TestLocationReportJSONRoundTrip(t *testing.T) {
166167
var raw map[string]json.RawMessage
167168
require.NoError(t, json.Unmarshal(data, &raw))
168169

169-
expectedFields := []string{"vehicle_id", "latitude", "longitude", "bearing", "speed", "accuracy", "timestamp"}
170+
expectedFields := []string{"vehicle_id", "route_id", "latitude", "longitude", "bearing", "speed", "accuracy", "timestamp"}
170171
for _, field := range expectedFields {
171172
assert.Contains(t, raw, field, "missing JSON field %q", field)
172173
}
@@ -176,6 +177,8 @@ func TestLocationReportJSONRoundTrip(t *testing.T) {
176177
var decoded locationReport
177178
require.NoError(t, json.Unmarshal(data, &decoded))
178179
assert.Equal(t, report, decoded)
180+
assert.Equal(t, "sim-route-1", decoded.RouteID)
181+
assert.Contains(t, string(data), `"route_id":"sim-route-1"`)
179182
}
180183

181184
func TestRouteWraparound(t *testing.T) {
@@ -293,7 +296,7 @@ func TestSimulateVehicle(t *testing.T) {
293296
ctx, cancel := context.WithTimeout(context.Background(), 350*time.Millisecond)
294297
defer cancel()
295298

296-
simulateVehicle(ctx, server.Client(), server.URL, "test-sim", route, 100*time.Millisecond, s)
299+
simulateVehicle(ctx, server.Client(), server.URL, "test-sim", route, "sim-route-1", 100*time.Millisecond, s)
297300

298301
assert.Eventually(t, func() bool {
299302
return s.succeeded.Load() >= 2

docs/development.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,8 @@ curl -X POST http://localhost:8080/api/v1/locations \
407407
-H 'Content-Type: application/json' \
408408
-d '{
409409
"vehicle_id": "demo-vehicle-42",
410-
"trip_id": "route-5-0830",
410+
"trip_id": "t_5_0830",
411+
"route_id": "5",
411412
"latitude": -1.2921,
412413
"longitude": 36.8219,
413414
"bearing": 180,

0 commit comments

Comments
 (0)