Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion gtfsdb/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ func (c *Client) processAndStoreGTFSDataWithSource(b []byte, source string) erro
ServiceID: t.Service.Id,
TripHeadsign: toNullString(t.Headsign),
TripShortName: toNullString(t.ShortName),
DirectionID: toNullInt64(int64(t.DirectionId)),
DirectionID: gtfsDirectionIDToDB(t.DirectionId),
BlockID: toNullString(t.BlockID),
ShapeID: toNullString(shapeID),
WheelchairAccessible: toNullInt64(int64(t.WheelchairAccessible)),
Expand Down Expand Up @@ -603,6 +603,23 @@ func toNullInt64(i int64) sql.NullInt64 {
return sql.NullInt64{}
}

// gtfsDirectionIDToDB converts a go-gtfs DirectionID enum back to the raw
// GTFS CSV value (0 or 1) for database storage. The go-gtfs enum numbers
// DirectionID_True=1 and DirectionID_False=2, which does not match the GTFS
// spec (direction_id is 0 or 1). Storing the raw CSV value keeps downstream
// code (ordering, Java-parity grouping, serialization) consistent with the
// GTFS spec and with onebusaway-application-modules.
func gtfsDirectionIDToDB(d gtfs.DirectionID) sql.NullInt64 {
switch d {
case gtfs.DirectionID_True:
return sql.NullInt64{Int64: 1, Valid: true}
case gtfs.DirectionID_False:
return sql.NullInt64{Int64: 0, Valid: true}
default:
return sql.NullInt64{}
}
}

func toNullFloat64(f float64) sql.NullFloat64 {
if f != 0 {
return sql.NullFloat64{
Expand Down
127 changes: 79 additions & 48 deletions internal/restapi/schedule_for_route_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,6 @@ func (api *RestAPI) scheduleForRouteHandler(w http.ResponseWriter, r *http.Reque
return
}

combinedServiceIDs := make([]string, 0, len(serviceIDs))
for _, sid := range serviceIDs {
combinedServiceIDs = append(combinedServiceIDs, utils.FormCombinedID(agencyID, sid))
}

trips, err := api.GtfsManager.GtfsDB.Queries.GetTripsForRouteInActiveServiceIDs(ctx, gtfsdb.GetTripsForRouteInActiveServiceIDsParams{
RouteID: routeID,
ServiceIds: serviceIDs,
Expand All @@ -102,6 +97,15 @@ func (api *RestAPI) scheduleForRouteHandler(w http.ResponseWriter, r *http.Reque
return
}

routeSvcIDs := make(map[string]bool)
combinedServiceIDs := make([]string, 0, len(trips))
for _, trip := range trips {
if !routeSvcIDs[trip.ServiceID] {
routeSvcIDs[trip.ServiceID] = true
combinedServiceIDs = append(combinedServiceIDs, utils.FormCombinedID(agencyID, trip.ServiceID))
}
}

// Handle case where service exists but this route has no trips today.
// Return 200 OK with empty data.
if len(trips) == 0 {
Expand Down Expand Up @@ -131,91 +135,115 @@ func (api *RestAPI) scheduleForRouteHandler(w http.ResponseWriter, r *http.Reque

routeRefs[utils.FormCombinedID(agencyID, route.ID)] = routeModel

groupings := make(map[string][]gtfsdb.Trip)
for _, trip := range trips {
tripIDsSet[trip.ID] = true
// The go-gtfs library encodes direction_id as a 3-value enum:
// 0 = Unspecified, 1 = True (GTFS direction_id=1), 2 = False (GTFS direction_id=0)
dirID := "0"
if trip.DirectionID.Int64 == 1 {
dirID = "1"
}
groupings[dirID] = append(groupings[dirID], trip)
}
dirGroups := groupTripsByDirection(trips)
var stopTripGroupings []models.StopTripGrouping
globalStopIDSet := make(map[string]struct{})
var stopTimesRefs [][]models.RouteStopTime
for dirID, groupedTrips := range groupings {

for _, group := range dirGroups {
if ctx.Err() != nil {
api.clientCanceledResponse(w, r, ctx.Err())
return
}

stopIDSet := make(map[string]struct{})
headsignSet := make(map[string]struct{})
tripIDs := make([]string, 0, len(groupedTrips))
tripsWithStopTimes := make([]models.TripStopTimes, 0, len(groupedTrips))
tripsInGroup := group.Trips

rawTripIDs := make([]string, 0, len(groupedTrips))
for _, trip := range groupedTrips {
rawTripIDs = append(rawTripIDs, trip.ID)
if trip.TripHeadsign.String != "" {
headsignSet[trip.TripHeadsign.String] = struct{}{}
seenDirSvcIDs := make(map[string]bool)
var dirServiceIDs []string
for _, trip := range tripsInGroup {
if !seenDirSvcIDs[trip.ServiceID] {
seenDirSvcIDs[trip.ServiceID] = true
dirServiceIDs = append(dirServiceIDs, trip.ServiceID)
}
}

var orderedStopIDs []string
var err error
if !group.DirectionID.Valid {
orderedStopIDs, err = api.GtfsManager.GtfsDB.Queries.GetOrderedStopIDsForTrip(ctx, tripsInGroup[0].ID)
} else {
orderedStopIDs, err = api.GtfsManager.GtfsDB.Queries.GetOrderedStopIDsForRouteDirection(ctx,
gtfsdb.GetOrderedStopIDsForRouteDirectionParams{
RouteID: routeID,
DirectionID: group.DirectionID,
ServiceIds: dirServiceIDs,
})
}
if err != nil {
api.serverErrorResponse(w, r, err)
return
}

for _, stopID := range orderedStopIDs {
globalStopIDSet[stopID] = struct{}{}
}

seenHeadsigns := make(map[string]bool)
var headsigns []string
for _, trip := range tripsInGroup {
hs := trip.TripHeadsign.String
if hs != "" && !seenHeadsigns[hs] {
seenHeadsigns[hs] = true
headsigns = append(headsigns, hs)
}
}

rawTripIDs := make([]string, 0, len(tripsInGroup))
for _, trip := range tripsInGroup {
rawTripIDs = append(rawTripIDs, trip.ID)
}

allStopTimes, err := api.GtfsManager.GtfsDB.Queries.GetStopTimesForTripIDs(ctx, rawTripIDs)
if err != nil {
api.Logger.Warn("failed to fetch stop times for trips in direction group", "dir_id", dirID, "error", err)
api.serverErrorResponse(w, r, err)
return
}

// Group stop times by trip ID (query returns rows ordered by trip_id, stop_sequence).
stopTimesByTrip := make(map[string][]gtfsdb.StopTime, len(groupedTrips))
stopTimesByTrip := make(map[string][]gtfsdb.StopTime, len(tripsInGroup))
for _, st := range allStopTimes {
stopTimesByTrip[st.TripID] = append(stopTimesByTrip[st.TripID], st)
}

for _, trip := range groupedTrips {
var tripIDs []string
var tripsWithStopTimes []models.TripStopTimes
for _, trip := range tripsInGroup {
stopTimes := stopTimesByTrip[trip.ID]
if len(stopTimes) == 0 {
continue
}
combinedTripID := utils.FormCombinedID(agencyID, trip.ID)
tripIDsSet[trip.ID] = true
tripIDs = append(tripIDs, combinedTripID)

stopTimesList := make([]models.RouteStopTime, 0, len(stopTimes))
for _, st := range stopTimes {
arrivalSec := int(utils.NanosToSeconds(st.ArrivalTime))
departureSec := int(utils.NanosToSeconds(st.DepartureTime))
stopTimesList = append(stopTimesList, models.RouteStopTime{
ArrivalEnabled: true,
ArrivalTime: arrivalSec,
ArrivalTime: int(utils.NanosToSeconds(st.ArrivalTime)),
DepartureEnabled: true,
DepartureTime: departureSec,
DepartureTime: int(utils.NanosToSeconds(st.DepartureTime)),
ServiceID: utils.FormCombinedID(agencyID, trip.ServiceID),
StopHeadsign: st.StopHeadsign.String,
StopID: utils.FormCombinedID(agencyID, st.StopID),
TripID: utils.FormCombinedID(agencyID, trip.ID),
TripID: combinedTripID,
})
stopIDSet[st.StopID] = struct{}{}
globalStopIDSet[st.StopID] = struct{}{}
}
tripIDs = append(tripIDs, utils.FormCombinedID(agencyID, trip.ID))
tripsWithStopTimes = append(tripsWithStopTimes, models.TripStopTimes{
TripID: utils.FormCombinedID(agencyID, trip.ID),
TripID: combinedTripID,
StopTimes: stopTimesList,
})
stopTimesRefs = append(stopTimesRefs, stopTimesList)
}
stopIDsOrdered := make([]string, 0, len(stopIDSet))
for stopID := range stopIDSet {
stopIDsOrdered = append(stopIDsOrdered, utils.FormCombinedID(agencyID, stopID))
}
headsigns := make([]string, 0, len(headsignSet))
for h := range headsignSet {
headsigns = append(headsigns, h)

formattedStopIDs := make([]string, len(orderedStopIDs))
for i, sid := range orderedStopIDs {
formattedStopIDs[i] = utils.FormCombinedID(agencyID, sid)
}

stopTripGroupings = append(stopTripGroupings, models.StopTripGrouping{
DirectionID: dirID,
DirectionID: group.GroupID,
TripHeadsigns: headsigns,
StopIDs: stopIDsOrdered,
StopIDs: formattedStopIDs,
TripIDs: tripIDs,
TripsWithStopTimes: tripsWithStopTimes,
})
Expand Down Expand Up @@ -252,6 +280,9 @@ func (api *RestAPI) scheduleForRouteHandler(w http.ResponseWriter, r *http.Reque

for _, t := range tripRows {
combinedTripID := utils.FormCombinedID(agencyID, t.ID)
// references.trips[].directionId carries the raw GTFS CSV value (0 or 1),
// matching Java OBA. stopTripGroupings[].directionId above is the Java-parity
// group index — the two fields share a name but have different semantics.
tripRef := models.NewTripReference(
combinedTripID,
t.RouteID,
Expand Down
87 changes: 87 additions & 0 deletions internal/restapi/schedule_for_route_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,93 @@ func TestScheduleForRouteHandlerDateParam(t *testing.T) {
})
}

// Regression for #790: serviceIds must be derived from the route's actual
// trips, not from the agency's active service IDs for the day. Route 25_1885
// uses only c_868_b_79978_d_31, while several other services are active on
// the same weekday — the response must include only the route-scoped set.
func TestScheduleForRouteHandler_ServiceIDsScopedToRoute(t *testing.T) {
clk := clock.NewMockClock(time.Date(2025, 6, 12, 12, 0, 0, 0, time.UTC))
api := createTestApiWithClock(t, clk)
defer api.Shutdown()

routeID := utils.FormCombinedID("25", "1885")
expectedServiceID := utils.FormCombinedID("25", "c_868_b_79978_d_31")

endpoint := "/api/where/schedule-for-route/" + routeID + ".json?key=TEST&date=2025-06-12"
resp, model := serveApiAndRetrieveEndpoint(t, api, endpoint)

assert.Equal(t, http.StatusOK, resp.StatusCode)

data, ok := model.Data.(map[string]interface{})
require.True(t, ok)
entry, ok := data["entry"].(map[string]interface{})
require.True(t, ok)

svcIdsRaw, ok := entry["serviceIds"].([]interface{})
require.True(t, ok)

svcIds := make([]string, 0, len(svcIdsRaw))
for _, v := range svcIdsRaw {
s, ok := v.(string)
require.True(t, ok)
svcIds = append(svcIds, s)
}

assert.ElementsMatch(t, []string{expectedServiceID}, svcIds,
"serviceIds must be scoped to the route's trips, not agency-wide active services")
}

// Regression: stopTripGroupings must follow the Java-OBA direction_id convention —
// groups are sorted so the higher CSV direction_id ("1") becomes group "0" and the
// lower ("0") becomes group "1". Trips inside each group must still carry their
// original CSV direction_id in the references section.
func TestScheduleForRouteHandler_DirectionIDJavaParity(t *testing.T) {
clk := clock.NewMockClock(time.Date(2025, 6, 12, 12, 0, 0, 0, time.UTC))
api := createTestApiWithClock(t, clk)
defer api.Shutdown()

routeID := utils.FormCombinedID("25", "1885")
endpoint := "/api/where/schedule-for-route/" + routeID + ".json?key=TEST&date=2025-06-12"
resp, model := serveApiAndRetrieveEndpoint(t, api, endpoint)
require.Equal(t, http.StatusOK, resp.StatusCode)

data, ok := model.Data.(map[string]interface{})
require.True(t, ok)
entry, ok := data["entry"].(map[string]interface{})
require.True(t, ok)

groupings, ok := entry["stopTripGroupings"].([]interface{})
require.True(t, ok)
require.Len(t, groupings, 2, "route 25_1885 has trips in both directions")

refs, ok := data["references"].(map[string]interface{})
require.True(t, ok)
tripRefs, ok := refs["trips"].([]interface{})
require.True(t, ok)

tripDirByID := make(map[string]string, len(tripRefs))
for _, tr := range tripRefs {
trMap := tr.(map[string]interface{})
tid, _ := trMap["id"].(string)
dir, _ := trMap["directionId"].(string)
tripDirByID[tid] = dir
}

// Expected Java-OBA mapping: group "0" ↔ CSV direction_id "1", group "1" ↔ CSV direction_id "0".
expected := map[string]string{"0": "1", "1": "0"}
for _, g := range groupings {
gMap := g.(map[string]interface{})
gid, _ := gMap["directionId"].(string)
tripIDs, _ := gMap["tripIds"].([]interface{})
require.NotEmpty(t, tripIDs)
for _, tid := range tripIDs {
ts, _ := tid.(string)
assert.Equal(t, expected[gid], tripDirByID[ts],
"group %s trip %s should have CSV direction_id %s", gid, ts, expected[gid])
}
}
}

func TestScheduleForRouteHandlerWithMalformedID(t *testing.T) {
api := createTestApi(t)
defer api.Shutdown()
Expand Down
Loading
Loading