1313import com .conveyal .file .FileUtils ;
1414import com .conveyal .gtfs .GTFSCache ;
1515import com .conveyal .gtfs .GTFSFeed ;
16- import com .conveyal .gtfs .error .GTFSError ;
1716import com .conveyal .gtfs .error .GeneralError ;
1817import com .conveyal .gtfs .model .Stop ;
1918import com .conveyal .gtfs .validator .PostLoadValidator ;
2019import com .conveyal .osmlib .Node ;
2120import com .conveyal .osmlib .OSM ;
22- import com .conveyal .r5 .analyst .progress .ProgressInputStream ;
2321import com .conveyal .r5 .analyst .cluster .TransportNetworkConfig ;
22+ import com .conveyal .r5 .analyst .progress .ProgressInputStream ;
2423import com .conveyal .r5 .analyst .progress .Task ;
2524import com .conveyal .r5 .streets .OSMCache ;
2625import com .conveyal .r5 .util .ExceptionUtils ;
@@ -81,6 +80,7 @@ public BundleController (BackendComponents components) {
8180 public void registerEndpoints (Service sparkService ) {
8281 sparkService .path ("/api/bundle" , () -> {
8382 sparkService .get ("" , this ::getBundles , toJson );
83+ sparkService .get ("/:_id/config" , this ::getBundleConfig , toJson );
8484 sparkService .get ("/:_id" , this ::getBundle , toJson );
8585 sparkService .post ("" , this ::create , toJson );
8686 sparkService .put ("/:_id" , this ::update , toJson );
@@ -110,15 +110,13 @@ private Bundle create (Request req, Response res) {
110110 try {
111111 bundle .name = files .get ("bundleName" ).get (0 ).getString ("UTF-8" );
112112 bundle .regionId = files .get ("regionId" ).get (0 ).getString ("UTF-8" );
113-
114113 if (files .get ("osmId" ) != null ) {
115114 bundle .osmId = files .get ("osmId" ).get (0 ).getString ("UTF-8" );
116115 Bundle bundleWithOsm = Persistence .bundles .find (QueryBuilder .start ("osmId" ).is (bundle .osmId ).get ()).next ();
117116 if (bundleWithOsm == null ) {
118117 throw AnalysisServerException .badRequest ("Selected OSM does not exist." );
119118 }
120119 }
121-
122120 if (files .get ("feedGroupId" ) != null ) {
123121 bundle .feedGroupId = files .get ("feedGroupId" ).get (0 ).getString ("UTF-8" );
124122 Bundle bundleWithFeed = Persistence .bundles .find (QueryBuilder .start ("feedGroupId" ).is (bundle .feedGroupId ).get ()).next ();
@@ -135,6 +133,13 @@ private Bundle create (Request req, Response res) {
135133 bundle .feedsComplete = bundleWithFeed .feedsComplete ;
136134 bundle .totalFeeds = bundleWithFeed .totalFeeds ;
137135 }
136+ if (files .get ("config" ) != null ) {
137+ // Validation by deserializing into a model class instance. Unknown fields are ignored to
138+ // allow sending config to custom or experimental workers with features unknown to the backend.
139+ // The fields specifying OSM and GTFS IDs are not expected here. They will be ignored and overwritten.
140+ String configString = files .get ("config" ).get (0 ).getString ();
141+ bundle .config = JsonUtil .objectMapper .readValue (configString , TransportNetworkConfig .class );
142+ }
138143 UserPermissions userPermissions = UserPermissions .from (req );
139144 bundle .accessGroup = userPermissions .accessGroup ;
140145 bundle .createdBy = userPermissions .email ;
@@ -274,15 +279,19 @@ private Bundle create (Request req, Response res) {
274279 return bundle ;
275280 }
276281
282+ /** SIDE EFFECTS: This method will change the field bundle.config before writing it. */
277283 private void writeNetworkConfigToCache (Bundle bundle ) throws IOException {
278- TransportNetworkConfig networkConfig = new TransportNetworkConfig ();
279- networkConfig .osmId = bundle .osmId ;
280- networkConfig .gtfsIds = bundle .feeds .stream ().map (f -> f .bundleScopedFeedId ).collect (Collectors .toList ());
281-
284+ // If the user specified additional network configuration options, they should already be in bundle.config.
285+ // If no custom options were specified, we start with a fresh, empty instance.
286+ if (bundle .config == null ) {
287+ bundle .config = new TransportNetworkConfig ();
288+ }
289+ // This will overwrite and override any inconsistent osm and gtfs IDs that were mistakenly supplied by the user.
290+ bundle .config .osmId = bundle .osmId ;
291+ bundle .config .gtfsIds = bundle .feeds .stream ().map (f -> f .bundleScopedFeedId ).collect (Collectors .toList ());
282292 String configFileName = bundle ._id + ".json" ;
283293 File configFile = FileUtils .createScratchFile ("json" );
284- JsonUtil .objectMapper .writeValue (configFile , networkConfig );
285-
294+ JsonUtil .objectMapper .writeValue (configFile , bundle .config );
286295 FileStorageKey key = new FileStorageKey (BUNDLES , configFileName );
287296 fileStorage .moveIntoStorage (key , configFile );
288297 }
@@ -312,6 +321,31 @@ private Bundle getBundle (Request req, Response res) {
312321 return bundle ;
313322 }
314323
324+ /**
325+ * There are two copies of the Bundle/Network config: one in the Bundle entry in the database and one in a JSON
326+ * file (obtainable by the workers). This method always reads the one in the file, which has been around longer
327+ * and is considered the definitive source of truth. The entry in the database is a newer addition and has only
328+ * been around since September 2024.
329+ */
330+ private TransportNetworkConfig getBundleConfig (Request request , Response res ) {
331+ // Unfortunately this mimics logic in TransportNetworkCache. Deduplicate in a static utility method?
332+ String id = GTFSCache .cleanId (request .params ("_id" ));
333+ FileStorageKey key = new FileStorageKey (BUNDLES , id , "json" );
334+ File networkConfigFile = fileStorage .getFile (key );
335+ if (!networkConfigFile .exists ()) {
336+ throw AnalysisServerException .notFound ("Bundle configuration file could not be found." );
337+ }
338+
339+ // Unlike in the worker, we expect the backend to have a model field for every known network/bundle option.
340+ // Therefore, use the default objectMapper that does not tolerate unknown fields.
341+ try {
342+ return JsonUtil .objectMapper .readValue (networkConfigFile , TransportNetworkConfig .class );
343+ } catch (Exception exception ) {
344+ LOG .error ("Exception deserializing stored network config" , exception );
345+ return null ;
346+ }
347+ }
348+
315349 private Collection <Bundle > getBundles (Request req , Response res ) {
316350 return Persistence .bundles .findPermittedForQuery (req );
317351 }
0 commit comments