Skip to content
Open
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
4 changes: 4 additions & 0 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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 |
Expand Down
20 changes: 12 additions & 8 deletions cmd/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions cmd/api/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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")
}
12 changes: 7 additions & 5 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions config.docker.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
14 changes: 14 additions & 0 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
51 changes: 40 additions & 11 deletions internal/appconf/json_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -253,6 +272,8 @@ type GtfsConfigData struct {
StaticAuthHeaderValue string
RTFeeds []RTFeedConfigData
GTFSDataPath string
RunningLateWindow int
RunningEarlyWindow int
Env Environment
Verbose bool
EnableGTFSTidy bool
Expand All @@ -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{})

Expand Down
42 changes: 42 additions & 0 deletions internal/appconf/json_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}

Expand Down
20 changes: 15 additions & 5 deletions internal/gtfs/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 18 additions & 7 deletions internal/restapi/trips_for_route_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading