Skip to content

Commit 0780dbd

Browse files
Merge pull request #462 from OneBusAway/fix/arrivals-null-route-bean-npe
Fix arrivals-and-departures returning bare "null" on unresolvable route refs
2 parents 095bf1a + 801de7b commit 0780dbd

6 files changed

Lines changed: 174 additions & 14 deletions

File tree

onebusaway-api-core/src/main/java/org/onebusaway/api/model/transit/BeanFactoryV2.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,8 @@ public StopV2Bean getStop(StopBean stop) {
471471

472472
List<String> routeIds = new ArrayList<String>();
473473
for (RouteBean route : stop.getRoutes()) {
474+
if (route == null)
475+
continue;
474476
routeIds.add(route.getId());
475477
addToReferences(route);
476478
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Copyright (C) 2026 OneBusAway
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.onebusaway.api.model.transit;
17+
18+
import static org.junit.Assert.assertTrue;
19+
20+
import java.util.Collections;
21+
22+
import org.junit.Test;
23+
import org.onebusaway.transit_data.model.RouteBean;
24+
import org.onebusaway.transit_data.model.StopBean;
25+
26+
public class BeanFactoryV2Test {
27+
28+
/**
29+
* A {@link StopBean} can carry a null {@link RouteBean} in its routes list
30+
* when the bundle's stop->route-collection index references a collection that
31+
* no longer resolves (the production trigger for issue #461). The serializer
32+
* must skip it rather than NPE, which would otherwise unwind out of the action
33+
* and emit a bare "null" response body.
34+
*/
35+
@Test
36+
public void getStopSkipsNullRoute() {
37+
BeanFactoryV2 factory = new BeanFactoryV2(true);
38+
39+
StopBean stop = new StopBean();
40+
stop.setId("1_stop");
41+
stop.setRoutes(Collections.singletonList((RouteBean) null));
42+
43+
StopV2Bean bean = factory.getStop(stop);
44+
45+
assertTrue(bean.getRouteIds().isEmpty());
46+
}
47+
}

onebusaway-api-webapp/src/main/java/org/onebusaway/api/actions/api/where/ArrivalsAndDeparturesForStopAction.java

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -141,18 +141,27 @@ public DefaultHttpHeaders show() throws ServiceException {
141141
return setResourceNotFoundResponse();
142142
}
143143

144-
if (isVersion(V1)) {
145-
// Convert data to v1 form
146-
List<ArrivalAndDepartureBeanV1> arrivals = getArrivalsAsV1(result);
147-
StopWithArrivalsAndDeparturesBeanV1 v1 = new StopWithArrivalsAndDeparturesBeanV1(
148-
result.getStop(), arrivals, result.getNearbyStops());
149-
return setOkResponse(v1);
150-
} else if (isVersion(V2)) {
151-
BeanFactoryV2 factory = getBeanFactoryV2();
152-
factory.setCustomRouteSort(customRouteSort);
153-
return setOkResponse(factory.getResponse(result));
154-
} else {
155-
return setUnknownVersionResponse();
144+
// Bean conversion must stay inside a try/catch: an unexpected failure here
145+
// (e.g. a corrupt bundle record) would otherwise unwind out of the action
146+
// with _response unset, and the ModelDriven serializer would emit a bare
147+
// "null" body with HTTP 200 instead of a proper error. See issue #461.
148+
try {
149+
if (isVersion(V1)) {
150+
// Convert data to v1 form
151+
List<ArrivalAndDepartureBeanV1> arrivals = getArrivalsAsV1(result);
152+
StopWithArrivalsAndDeparturesBeanV1 v1 = new StopWithArrivalsAndDeparturesBeanV1(
153+
result.getStop(), arrivals, result.getNearbyStops());
154+
return setOkResponse(v1);
155+
} else if (isVersion(V2)) {
156+
BeanFactoryV2 factory = getBeanFactoryV2();
157+
factory.setCustomRouteSort(customRouteSort);
158+
return setOkResponse(factory.getResponse(result));
159+
} else {
160+
return setUnknownVersionResponse();
161+
}
162+
} catch (Exception any) {
163+
_log.error("Failed to build arrivals-and-departures response for stop {}", _id, any);
164+
return setExceptionResponse();
156165
}
157166
}
158167

onebusaway-transit-data-federation/src/main/java/org/onebusaway/transit_data_federation/impl/beans/StopBeanServiceImpl.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,16 @@
3939
import org.onebusaway.transit_data_federation.services.transit_graph.TransitGraphDao;
4040
import org.onebusaway.utility.text.NaturalStringOrder;
4141
import org.onebusaway.utility.text.StringLibrary;
42+
import org.slf4j.Logger;
43+
import org.slf4j.LoggerFactory;
4244
import org.springframework.beans.factory.annotation.Autowired;
4345
import org.springframework.stereotype.Component;
4446

4547
@Component
4648
class StopBeanServiceImpl implements StopBeanService {
4749

50+
private static final Logger _log = LoggerFactory.getLogger(StopBeanServiceImpl.class);
51+
4852
private static RouteBeanComparator _routeBeanComparator = new RouteBeanComparator();
4953

5054
private TransitGraphDao _transitGraphDao;
@@ -143,6 +147,12 @@ private void fillTransfersForStopBean(StopEntry stop, StopNarrative narrative, S
143147
List<RouteBean> routeBeans = new ArrayList<>(staticRoutes.size());
144148
for (AgencyAndId routeId : staticRoutes) {
145149
RouteBean staticRouteBean = _routeBeanService.getRouteForId(routeId);
150+
if (staticRouteBean == null) {
151+
// Same orphan-route guard as fillRoutesForStopBean below; see issue #461.
152+
_log.warn("stop {} references static route {} that does not resolve to a route; skipping",
153+
stop.getId(), routeId);
154+
continue;
155+
}
146156
routeBeans.add(staticRouteBean);
147157
}
148158
bean.setStaticRoutes(routeBeans);
@@ -162,6 +172,15 @@ private void fillRoutesForStopBean(StopEntry stop, StopBean sb, AgencyServiceInt
162172

163173
for (AgencyAndId routeCollectionId : routeCollectionIds) {
164174
RouteBean bean = _routeBeanService.getRouteForId(routeCollectionId);
175+
if (bean == null) {
176+
// A corrupt stop->route-collection index entry can reference a
177+
// collection that no longer resolves to a route. Dropping it here keeps
178+
// a single bad entry from NPEing bean serialization and blanking the
179+
// entire API response. See issue #461.
180+
_log.warn("stop {} references route collection {} that does not resolve to a route; skipping",
181+
stop.getId(), routeCollectionId);
182+
continue;
183+
}
165184
routeBeans.add(bean);
166185
}
167186

onebusaway-transit-data-federation/src/main/java/org/onebusaway/transit_data_federation/impl/beans/StopWithArrivalsAndDeparturesBeanServiceImpl.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,14 @@ public StopWithArrivalsAndDeparturesBean getArrivalsAndDeparturesByStopId(
7474
List<AgencyAndId> nearbyStopIds = _nearbyStopsBeanService.getNearbyStops(
7575
stop, 100);
7676
List<StopBean> nearbyStops = new ArrayList<StopBean>();
77-
for (AgencyAndId nearbyStopId : nearbyStopIds)
78-
nearbyStops.add(_stopBeanService.getStopForId(nearbyStopId, serviceInterval));
77+
for (AgencyAndId nearbyStopId : nearbyStopIds) {
78+
StopBean nearbyStop = _stopBeanService.getStopForId(nearbyStopId, serviceInterval);
79+
if (nearbyStop == null) {
80+
_log.warn("nearby stop {} did not resolve to a stop; skipping", nearbyStopId);
81+
continue;
82+
}
83+
nearbyStops.add(nearbyStop);
84+
}
7985

8086
List<ServiceAlertBean> situations = _serviceAlertsBeanService.getServiceAlertsForStopId(
8187
query.getTime(), id);

onebusaway-transit-data-federation/src/test/java/org/onebusaway/transit_data_federation/impl/beans/StopBeanServiceImplTest.java

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
import static org.junit.Assert.assertEquals;
2020
import static org.junit.Assert.assertNotNull;
2121
import static org.junit.Assert.assertSame;
22+
import static org.junit.Assert.assertTrue;
2223

24+
import java.util.Collections;
2325
import java.util.HashSet;
2426
import java.util.List;
2527
import java.util.Set;
@@ -116,4 +118,79 @@ public void testGetStopForId() {
116118

117119
assertSame(route, routes.get(0));
118120
}
121+
122+
/**
123+
* A stop's route-collection index can reference a collection that no longer
124+
* resolves to a route bean (e.g. a corrupt service-date entry in the bundle).
125+
* {@link RouteBeanService#getRouteForId} then returns null. We must not let a
126+
* null {@link RouteBean} leak into the stop's routes list: in production a
127+
* single such orphan route survives the sort (a lone element is never
128+
* compared) and later NPEs in bean serialization, blanking the whole API
129+
* response with a literal "null".
130+
*/
131+
@Test
132+
public void testGetStopForId_skipsUnresolvableRoute() {
133+
134+
AgencyAndId stopId = new AgencyAndId("29", "1109");
135+
136+
StopEntryImpl stopEntry = new StopEntryImpl(stopId, 47.1, -122.1);
137+
Mockito.when(_transitGraphDao.getStopEntryForId(stopId)).thenReturn(
138+
stopEntry);
139+
140+
StopNarrative.Builder builder = StopNarrative.builder();
141+
builder.setName("stop name");
142+
StopNarrative stop = builder.create();
143+
Mockito.when(_narrativeService.getStopForId(stopId)).thenReturn(stop);
144+
145+
AgencyAndId orphanRouteId = new AgencyAndId("1", "orphan");
146+
147+
Set<AgencyAndId> routeIds = new HashSet<AgencyAndId>();
148+
routeIds.add(orphanRouteId);
149+
Mockito.when(_routeService.getRouteCollectionIdsForStop(stopId)).thenReturn(
150+
routeIds);
151+
152+
// the corrupt collection id resolves to null
153+
Mockito.when(_routeBeanService.getRouteForId(orphanRouteId)).thenReturn(null);
154+
155+
StopBean stopBean = _service.getStopForId(stopId, null);
156+
157+
assertNotNull(stopBean);
158+
List<RouteBean> routes = stopBean.getRoutes();
159+
assertTrue("unresolvable route must not leak into routes list", routes.isEmpty());
160+
}
161+
162+
/**
163+
* The static-routes path resolves route beans the same way as the primary
164+
* routes path, so an unresolvable static route must likewise be dropped
165+
* rather than leak a null into the stop's static routes list. See issue #461.
166+
*/
167+
@Test
168+
public void testGetStopForId_skipsUnresolvableStaticRoute() {
169+
170+
AgencyAndId stopId = new AgencyAndId("29", "1109");
171+
172+
StopEntryImpl stopEntry = new StopEntryImpl(stopId, 47.1, -122.1);
173+
Mockito.when(_transitGraphDao.getStopEntryForId(stopId)).thenReturn(
174+
stopEntry);
175+
176+
StopNarrative.Builder builder = StopNarrative.builder();
177+
builder.setName("stop name");
178+
Mockito.when(_narrativeService.getStopForId(stopId)).thenReturn(builder.create());
179+
180+
// no active routes, so only the static-route path populates the bean
181+
Mockito.when(_routeService.getRouteCollectionIdsForStop(stopId)).thenReturn(
182+
new HashSet<AgencyAndId>());
183+
184+
AgencyAndId orphanStaticRouteId = new AgencyAndId("1", "orphan");
185+
Mockito.when(_narrativeService.getStaticRoutes(stopId)).thenReturn(
186+
Collections.singletonList(orphanStaticRouteId));
187+
// the corrupt static route resolves to null
188+
Mockito.when(_routeBeanService.getRouteForId(orphanStaticRouteId)).thenReturn(null);
189+
190+
StopBean stopBean = _service.getStopForId(stopId, null);
191+
192+
assertNotNull(stopBean);
193+
assertTrue("unresolvable static route must not leak into static routes list",
194+
stopBean.getStaticRoutes().isEmpty());
195+
}
119196
}

0 commit comments

Comments
 (0)