From 8c3c4ffbc25f39b0cf6d2d0227411cd57c81b9b4 Mon Sep 17 00:00:00 2001 From: cotishq Date: Sat, 4 Apr 2026 12:16:34 +0530 Subject: [PATCH] feat(config): make trips-for-route running windows configurable --- README.markdown | 4 ++ cmd/api/app.go | 20 +++++--- cmd/api/app_test.go | 6 +++ cmd/api/main.go | 12 +++-- config.docker.example.json | 2 + config.example.json | 2 + config.schema.json | 14 +++++ internal/appconf/json_config.go | 51 +++++++++++++++---- internal/appconf/json_config_test.go | 42 +++++++++++++++ internal/gtfs/config.go | 20 ++++++-- internal/restapi/trips_for_route_handler.go | 25 ++++++--- .../restapi/trips_for_route_handler_test.go | 31 +++++++++++ testdata/config_full.json | 2 + 13 files changed, 195 insertions(+), 36 deletions(-) diff --git a/README.markdown b/README.markdown index 6a9fcdff..0f8dbb48 100644 --- a/README.markdown +++ b/README.markdown @@ -89,6 +89,8 @@ Example `config.json`: "env": "production", "api-keys": ["key1", "key2", "key3"], "rate-limit": 50, + "running-late-window": 1800, + "running-early-window": 600, "log-level": "info", "log-format": "json", "gtfs-static-feed": { @@ -156,6 +158,8 @@ A JSON schema file is provided at `config.schema.json` for IDE autocomplete and | `log-level` | string | "info" | Log level (debug, info, warn, error) | | `log-format` | string | "text" | Log format (text, json) | | `rate-limit` | integer | 100 | Requests per second per API key | +| `running-late-window` | integer | 1800 | Trips-for-route late window in seconds | +| `running-early-window` | integer | 600 | Trips-for-route early window in seconds | | `gtfs-static-feed` | object | (Sound Transit) | Static GTFS feed configuration | | `gtfs-rt-feeds` | array | (Sound Transit) | GTFS-RT feed configurations (see below) | | `data-path` | string | "./gtfs.db" | Path to SQLite database | diff --git a/cmd/api/app.go b/cmd/api/app.go index cc7bf031..1fcba88c 100644 --- a/cmd/api/app.go +++ b/cmd/api/app.go @@ -30,6 +30,8 @@ func gtfsConfigFromData(gtfsCfgData appconf.GtfsConfigData) gtfs.Config { StaticAuthHeaderKey: gtfsCfgData.StaticAuthHeaderKey, StaticAuthHeaderValue: gtfsCfgData.StaticAuthHeaderValue, GTFSDataPath: gtfsCfgData.GTFSDataPath, + RunningLateWindow: time.Duration(gtfsCfgData.RunningLateWindow) * time.Second, + RunningEarlyWindow: time.Duration(gtfsCfgData.RunningEarlyWindow) * time.Second, Env: gtfsCfgData.Env, Verbose: gtfsCfgData.Verbose, EnableGTFSTidy: gtfsCfgData.EnableGTFSTidy, @@ -290,14 +292,16 @@ func dumpConfigJSON(cfg appconf.Config, gtfsCfg gtfs.Config) { } // Build JSON config structure - jsonConfig := map[string]any{ - "port": cfg.Port, - "env": envStr, - "api-keys": fmt.Sprintf("***REDACTED*** (%d keys)", len(cfg.ApiKeys)), - "exempt-api-keys": fmt.Sprintf("***REDACTED*** (%d keys)", len(cfg.ExemptApiKeys)), - "rate-limit": cfg.RateLimit, - "gtfs-static-feed": staticFeed, - "data-path": gtfsCfg.GTFSDataPath, + jsonConfig := map[string]interface{}{ + "port": cfg.Port, + "env": envStr, + "api-keys": fmt.Sprintf("***REDACTED*** (%d keys)", len(cfg.ApiKeys)), + "exempt-api-keys": fmt.Sprintf("***REDACTED*** (%d keys)", len(cfg.ExemptApiKeys)), + "rate-limit": cfg.RateLimit, + "running-late-window": int(gtfsCfg.RunningLateWindow.Seconds()), + "running-early-window": int(gtfsCfg.RunningEarlyWindow.Seconds()), + "gtfs-static-feed": staticFeed, + "data-path": gtfsCfg.GTFSDataPath, } var feeds []map[string]any diff --git a/cmd/api/app_test.go b/cmd/api/app_test.go index b8b26fe9..df0ba2ce 100644 --- a/cmd/api/app_test.go +++ b/cmd/api/app_test.go @@ -414,6 +414,8 @@ func TestConfigFileLoading(t *testing.T) { // Verify GTFS config assert.Equal(t, appconf.Development, gtfsCfgData.Env) assert.True(t, gtfsCfgData.Verbose) + assert.Equal(t, 1800, gtfsCfgData.RunningLateWindow) + assert.Equal(t, 600, gtfsCfgData.RunningEarlyWindow) }) t.Run("loads full config file with GTFS-RT feed", func(t *testing.T) { @@ -433,6 +435,8 @@ func TestConfigFileLoading(t *testing.T) { assert.Equal(t, 50, appCfg.RateLimit) // Verify GTFS config - feeds are now in RTFeeds + assert.Equal(t, 2400, gtfsCfgData.RunningLateWindow) + assert.Equal(t, 900, gtfsCfgData.RunningEarlyWindow) require.NotEmpty(t, gtfsCfgData.RTFeeds) feed0 := gtfsCfgData.RTFeeds[0] assert.Equal(t, "https://api.example.com/trip-updates.pb", feed0.TripUpdatesURL) @@ -624,4 +628,6 @@ func TestDumpConfigJSON_WithExampleFile(t *testing.T) { assert.NotEqual(t, "", rtFeed["trip-updates-url"]) assert.Equal(t, gtfsCfg.GTFSDataPath, parsed["data-path"]) + assert.Contains(t, parsed, "running-late-window") + assert.Contains(t, parsed, "running-early-window") } diff --git a/cmd/api/main.go b/cmd/api/main.go index 5bfccb62..3e75c78f 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -113,11 +113,13 @@ func main() { // Pack the CLI flags into a temporary JSONConfig struct // This allows us to run the exact same robust validation logic as the JSON path! cliConfig := appconf.JSONConfig{ - Port: cfg.Port, - Env: envFlag, - ApiKeys: ParseAPIKeys(apiKeysFlag), - ExemptApiKeys: ParseAPIKeys(exemptApiKeysFlag), - RateLimit: cfg.RateLimit, + Port: cfg.Port, + Env: envFlag, + ApiKeys: ParseAPIKeys(apiKeysFlag), + ExemptApiKeys: ParseAPIKeys(exemptApiKeysFlag), + RateLimit: cfg.RateLimit, + RunningLateWindow: 30 * 60, + RunningEarlyWindow: 10 * 60, GtfsStaticFeed: appconf.GtfsStaticFeed{ URL: gtfsCfg.GtfsURL, AuthHeaderName: gtfsCfg.StaticAuthHeaderKey, diff --git a/config.docker.example.json b/config.docker.example.json index 1344ea57..ba18d1ec 100644 --- a/config.docker.example.json +++ b/config.docker.example.json @@ -13,6 +13,8 @@ ], "_comment": "WARNING: Change 'api-keys' before deploying to production! The default 'test' key is for development only.", "rate-limit": 100, + "running-late-window": 1800, + "running-early-window": 600, "gtfs-static-feed": { "url": "https://www.soundtransit.org/GTFS-rail/40_gtfs.zip", "enable-gtfs-tidy": false diff --git a/config.example.json b/config.example.json index 96c7f38c..851e7bca 100644 --- a/config.example.json +++ b/config.example.json @@ -12,6 +12,8 @@ "org.onebusaway.iphone" ], "rate-limit": 100, + "running-late-window": 1800, + "running-early-window": 600, "log-level": "info", "log-format": "text", "gtfs-static-feed": { diff --git a/config.schema.json b/config.schema.json index dbffc350..80506b65 100644 --- a/config.schema.json +++ b/config.schema.json @@ -67,6 +67,18 @@ "default": 100, "minimum": 1 }, + "running-late-window": { + "type": "integer", + "description": "Trips-for-route late window in seconds (look behind now for late trips)", + "default": 1800, + "minimum": 0 + }, + "running-early-window": { + "type": "integer", + "description": "Trips-for-route early window in seconds (look ahead now for early trips)", + "default": 600, + "minimum": 0 + }, "gtfs-static-feed": { "type": "object", "description": "Configuration for the static GTFS feed", @@ -181,6 +193,8 @@ "api-keys": ["key1", "key2", "key3"], "protected-api-keys": ["secret1", "secret2"], "rate-limit": 50, + "running-late-window": 1800, + "running-early-window": 600, "gtfs-static-feed": { "url": "https://example.com/gtfs.zip", "auth-header-name": "Authorization", diff --git a/internal/appconf/json_config.go b/internal/appconf/json_config.go index 1fc616b1..aa9e6fdf 100644 --- a/internal/appconf/json_config.go +++ b/internal/appconf/json_config.go @@ -33,19 +33,26 @@ type GtfsRtFeed struct { // JSONConfig represents the JSON configuration file structure type JSONConfig struct { - Port int `json:"port"` - Env string `json:"env"` - ApiKeys []string `json:"api-keys"` - ProtectedApiKeys []string `json:"protected-api-keys"` - ExemptApiKeys []string `json:"exempt-api-keys"` - RateLimit int `json:"rate-limit"` - GtfsStaticFeed GtfsStaticFeed `json:"gtfs-static-feed"` - GtfsRtFeeds []GtfsRtFeed `json:"gtfs-rt-feeds"` - DataPath string `json:"data-path"` - LogLevel string `json:"log-level"` - LogFormat string `json:"log-format"` + Port int `json:"port"` + Env string `json:"env"` + ApiKeys []string `json:"api-keys"` + ProtectedApiKeys []string `json:"protected-api-keys"` + ExemptApiKeys []string `json:"exempt-api-keys"` + RateLimit int `json:"rate-limit"` + RunningLateWindow int `json:"running-late-window"` + RunningEarlyWindow int `json:"running-early-window"` + GtfsStaticFeed GtfsStaticFeed `json:"gtfs-static-feed"` + GtfsRtFeeds []GtfsRtFeed `json:"gtfs-rt-feeds"` + DataPath string `json:"data-path"` + LogLevel string `json:"log-level"` + LogFormat string `json:"log-format"` } +const ( + defaultRunningLateWindowSeconds = 30 * 60 + defaultRunningEarlyWindowSeconds = 10 * 60 +) + // setDefaults applies default values to the JSON config if fields are missing or zero func (j *JSONConfig) setDefaults() { if j.Port == 0 { @@ -66,6 +73,12 @@ func (j *JSONConfig) setDefaults() { if j.RateLimit == 0 { j.RateLimit = 100 } + if j.RunningLateWindow == 0 { + j.RunningLateWindow = defaultRunningLateWindowSeconds + } + if j.RunningEarlyWindow == 0 { + j.RunningEarlyWindow = defaultRunningEarlyWindowSeconds + } if j.GtfsStaticFeed.URL == "" { j.GtfsStaticFeed.URL = "https://www.soundtransit.org/GTFS-rail/40_gtfs.zip" } @@ -106,6 +119,12 @@ func (j *JSONConfig) Validate() error { if j.RateLimit < 1 { return fmt.Errorf("rate-limit must be at least 1, got %d", j.RateLimit) } + if j.RunningLateWindow < 0 { + return fmt.Errorf("running-late-window cannot be negative, got %d", j.RunningLateWindow) + } + if j.RunningEarlyWindow < 0 { + return fmt.Errorf("running-early-window cannot be negative, got %d", j.RunningEarlyWindow) + } if len(j.ApiKeys) == 0 { return fmt.Errorf("api-keys cannot be empty") @@ -253,6 +272,8 @@ type GtfsConfigData struct { StaticAuthHeaderValue string RTFeeds []RTFeedConfigData GTFSDataPath string + RunningLateWindow int + RunningEarlyWindow int Env Environment Verbose bool EnableGTFSTidy bool @@ -265,10 +286,18 @@ func (j *JSONConfig) ToGtfsConfigData() (GtfsConfigData, error) { StaticAuthHeaderKey: j.GtfsStaticFeed.AuthHeaderName, StaticAuthHeaderValue: j.GtfsStaticFeed.AuthHeaderValue, GTFSDataPath: j.DataPath, + RunningLateWindow: j.RunningLateWindow, + RunningEarlyWindow: j.RunningEarlyWindow, Env: EnvFlagToEnvironment(j.Env), Verbose: true, // Always set to true like in main.go EnableGTFSTidy: j.GtfsStaticFeed.EnableGTFSTidy, } + if cfg.RunningLateWindow <= 0 { + cfg.RunningLateWindow = defaultRunningLateWindowSeconds + } + if cfg.RunningEarlyWindow <= 0 { + cfg.RunningEarlyWindow = defaultRunningEarlyWindowSeconds + } seen := make(map[string]struct{}) diff --git a/internal/appconf/json_config_test.go b/internal/appconf/json_config_test.go index 102657d8..b3bdb063 100644 --- a/internal/appconf/json_config_test.go +++ b/internal/appconf/json_config_test.go @@ -20,6 +20,8 @@ func TestLoadFromFile_ValidConfig(t *testing.T) { // Verify defaults were applied assert.Equal(t, []string{"test"}, config.ApiKeys) assert.Equal(t, 100, config.RateLimit) + assert.Equal(t, 1800, config.RunningLateWindow) + assert.Equal(t, 600, config.RunningEarlyWindow) assert.Equal(t, "https://www.soundtransit.org/GTFS-rail/40_gtfs.zip", config.GtfsStaticFeed.URL) assert.Equal(t, "./gtfs.db", config.DataPath) assert.Len(t, config.GtfsRtFeeds, 1) @@ -38,6 +40,8 @@ func TestLoadFromFile_FullConfig(t *testing.T) { assert.Equal(t, []string{"key1", "key2", "key3"}, config.ApiKeys) assert.Equal(t, []string{"protected-key-1", "protected-key-2"}, config.ProtectedApiKeys) assert.Equal(t, 50, config.RateLimit) + assert.Equal(t, 2400, config.RunningLateWindow) + assert.Equal(t, 900, config.RunningEarlyWindow) assert.Equal(t, "debug", config.LogLevel) assert.Equal(t, "json", config.LogFormat) assert.Equal(t, "https://example.com/gtfs.zip", config.GtfsStaticFeed.URL) @@ -128,6 +132,36 @@ func TestValidate_InvalidRateLimit(t *testing.T) { assert.Contains(t, err.Error(), "rate-limit must be at least 1") } +func TestValidate_InvalidRunningLateWindow(t *testing.T) { + config := &JSONConfig{ + Port: 4000, + Env: "development", + ApiKeys: []string{"test"}, + ProtectedApiKeys: []string{"test"}, + RateLimit: 100, + RunningLateWindow: -1, + RunningEarlyWindow: 600, + } + err := config.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "running-late-window cannot be negative") +} + +func TestValidate_InvalidRunningEarlyWindow(t *testing.T) { + config := &JSONConfig{ + Port: 4000, + Env: "development", + ApiKeys: []string{"test"}, + ProtectedApiKeys: []string{"test"}, + RateLimit: 100, + RunningLateWindow: 1800, + RunningEarlyWindow: -1, + } + err := config.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "running-early-window cannot be negative") +} + func TestValidate_InvalidLogLevel(t *testing.T) { config := &JSONConfig{ Port: 4000, @@ -266,6 +300,8 @@ func TestToGtfsConfigData_NoFeeds(t *testing.T) { assert.Equal(t, "X-API-Key", gtfsConfig.StaticAuthHeaderKey) assert.Equal(t, "secret123", gtfsConfig.StaticAuthHeaderValue) assert.Equal(t, "/data/gtfs.db", gtfsConfig.GTFSDataPath) + assert.Equal(t, 1800, gtfsConfig.RunningLateWindow) + assert.Equal(t, 600, gtfsConfig.RunningEarlyWindow) assert.Equal(t, Development, gtfsConfig.Env) assert.True(t, gtfsConfig.Verbose) @@ -301,6 +337,8 @@ func TestToGtfsConfigData_WithMultipleFeeds(t *testing.T) { // Both feeds should be present require.Len(t, gtfsConfig.RTFeeds, 2) + assert.Equal(t, 1800, gtfsConfig.RunningLateWindow) + assert.Equal(t, 600, gtfsConfig.RunningEarlyWindow) // First feed feed0 := gtfsConfig.RTFeeds[0] @@ -328,6 +366,8 @@ func TestSetDefaults(t *testing.T) { assert.Equal(t, "development", config.Env) assert.Equal(t, []string{"test"}, config.ApiKeys) assert.Equal(t, 100, config.RateLimit) + assert.Equal(t, 1800, config.RunningLateWindow) + assert.Equal(t, 600, config.RunningEarlyWindow) assert.Equal(t, "https://www.soundtransit.org/GTFS-rail/40_gtfs.zip", config.GtfsStaticFeed.URL) assert.Equal(t, "./gtfs.db", config.DataPath) assert.Len(t, config.GtfsRtFeeds, 1) @@ -349,6 +389,8 @@ func TestSetDefaults_PartialConfig(t *testing.T) { // Missing values should get defaults assert.Equal(t, "development", config.Env) assert.Equal(t, 100, config.RateLimit) + assert.Equal(t, 1800, config.RunningLateWindow) + assert.Equal(t, 600, config.RunningEarlyWindow) assert.Equal(t, "https://www.soundtransit.org/GTFS-rail/40_gtfs.zip", config.GtfsStaticFeed.URL) } diff --git a/internal/gtfs/config.go b/internal/gtfs/config.go index e1e194fc..5a48b061 100644 --- a/internal/gtfs/config.go +++ b/internal/gtfs/config.go @@ -26,13 +26,23 @@ type Config struct { StaticAuthHeaderValue string RTFeeds []RTFeedConfig GTFSDataPath string - Env appconf.Environment - Verbose bool - EnableGTFSTidy bool - StartupRetries []time.Duration - Metrics *metrics.Metrics + // runningLateWindow and runningEarlyWindow tune trips-for-route time range selection. + RunningLateWindow time.Duration + RunningEarlyWindow time.Duration + Env appconf.Environment + Verbose bool + EnableGTFSTidy bool + StartupRetries []time.Duration + Metrics *metrics.Metrics } +const ( + // DefaultRunningLateWindow is how far behind "now" trips-for-route looks for still-relevant late trips. + DefaultRunningLateWindow = 30 * time.Minute + // DefaultRunningEarlyWindow is how far ahead "now" trips-for-route looks for early trips. + DefaultRunningEarlyWindow = 10 * time.Minute +) + // enabledFeeds returns only the enabled feeds that have at least one URL configured. func (config Config) enabledFeeds() []RTFeedConfig { var feeds []RTFeedConfig diff --git a/internal/restapi/trips_for_route_handler.go b/internal/restapi/trips_for_route_handler.go index 5de08a26..360783b0 100644 --- a/internal/restapi/trips_for_route_handler.go +++ b/internal/restapi/trips_for_route_handler.go @@ -69,13 +69,8 @@ func (api *RestAPI) tripsForRouteHandler(w http.ResponseWriter, r *http.Request) // Check the previous day's service for trips running past midnight. // GTFS allows departure times > 24:00:00 (e.g., 25:30:00 = 1:30 AM next day). // These trips belong to yesterday's service but are still active now. - // TODO: We should add config for runningLateWindow and runningEarlyWindow like Java OBA - // source:https://groups.google.com/g/onebusaway-developers/c/j-G-1UyfbXI/m/J-Su3BArKW0J - const ( - oneDayNanos = int64(24 * 60 * 60 * 1_000_000_000) - runningLateNanos = int64(30 * 60 * 1_000_000_000) // runningLateWindow - runningEarlyNanos = int64(10 * 60 * 1_000_000_000) // runningEarlyWindow - ) + const oneDayNanos = int64(24 * 60 * 60 * 1_000_000_000) + runningLateNanos, runningEarlyNanos := api.tripsForRouteWindowNanos() prevDay := currentTime.AddDate(0, 0, -1) prevFormattedDate := prevDay.Format("20060102") prevServiceIDs, err := api.GtfsManager.GtfsDB.Queries.GetActiveServiceIDsForDate(ctx, prevFormattedDate) @@ -436,6 +431,22 @@ func (api *RestAPI) tripsForRouteHandler(w http.ResponseWriter, r *http.Request) api.sendResponse(w, r, response) } +func (api *RestAPI) tripsForRouteWindowNanos() (runningLateNanos int64, runningEarlyNanos int64) { + runningLate := gtfsInternal.DefaultRunningLateWindow + runningEarly := gtfsInternal.DefaultRunningEarlyWindow + + if api != nil { + if api.GtfsConfig.RunningLateWindow > 0 { + runningLate = api.GtfsConfig.RunningLateWindow + } + if api.GtfsConfig.RunningEarlyWindow > 0 { + runningEarly = api.GtfsConfig.RunningEarlyWindow + } + } + + return runningLate.Nanoseconds(), runningEarly.Nanoseconds() +} + func collectStopIDsFromSchedule(schedule *models.TripsSchedule, stopIDsMap map[string]bool) { if schedule == nil { return diff --git a/internal/restapi/trips_for_route_handler_test.go b/internal/restapi/trips_for_route_handler_test.go index be8b5a85..4cc4c3ab 100644 --- a/internal/restapi/trips_for_route_handler_test.go +++ b/internal/restapi/trips_for_route_handler_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "maglev.onebusaway.org/gtfsdb" + "maglev.onebusaway.org/internal/app" + "maglev.onebusaway.org/internal/gtfs" "maglev.onebusaway.org/internal/models" ) @@ -337,3 +339,32 @@ func TestCollectStopIDsFromSchedule_EmptyStopTimes(t *testing.T) { collectStopIDsFromSchedule(schedule, stopIDsMap) assert.Empty(t, stopIDsMap) } + +func TestTripsForRouteWindowNanos_Defaults(t *testing.T) { + api := &RestAPI{ + Application: &app.Application{ + GtfsConfig: gtfs.Config{}, + }, + } + + lateNanos, earlyNanos := api.tripsForRouteWindowNanos() + + assert.Equal(t, gtfs.DefaultRunningLateWindow.Nanoseconds(), lateNanos) + assert.Equal(t, gtfs.DefaultRunningEarlyWindow.Nanoseconds(), earlyNanos) +} + +func TestTripsForRouteWindowNanos_CustomConfig(t *testing.T) { + api := &RestAPI{ + Application: &app.Application{ + GtfsConfig: gtfs.Config{ + RunningLateWindow: 45 * time.Minute, + RunningEarlyWindow: 3 * time.Minute, + }, + }, + } + + lateNanos, earlyNanos := api.tripsForRouteWindowNanos() + + assert.Equal(t, (45 * time.Minute).Nanoseconds(), lateNanos) + assert.Equal(t, (3 * time.Minute).Nanoseconds(), earlyNanos) +} diff --git a/testdata/config_full.json b/testdata/config_full.json index 95f291ea..322b2ec1 100644 --- a/testdata/config_full.json +++ b/testdata/config_full.json @@ -12,6 +12,8 @@ "protected-key-2" ], "rate-limit": 50, + "running-late-window": 2400, + "running-early-window": 900, "gtfs-static-feed": { "url": "https://example.com/gtfs.zip", "auth-header-name": "Authorization",