@@ -4,12 +4,16 @@ import (
44 "context"
55 "encoding/json"
66 "errors"
7+ "fmt"
78 "os"
9+ "path/filepath"
810 "time"
911
1012 "github.com/interline-io/log"
13+ "github.com/interline-io/transitland-lib/dmfr"
1114 "github.com/interline-io/transitland-lib/ext"
1215 "github.com/interline-io/transitland-lib/internal/snakejson"
16+ "github.com/interline-io/transitland-lib/request"
1317 "github.com/interline-io/transitland-lib/tlcli"
1418 "github.com/interline-io/transitland-lib/tldb"
1519 "github.com/interline-io/transitland-lib/validator"
@@ -30,6 +34,15 @@ type ValidatorCommand struct {
3034 ValidationReportStorage string
3135 readerPath string
3236 errorThresholds []string
37+ SecretsFile string
38+ SecretEnv []string
39+ DMFRFile string
40+ FeedID string
41+ URLType string
42+ AllowFTPFetch bool
43+ AllowLocalFetch bool
44+ AllowS3Fetch bool
45+ secrets []dmfr.Secret
3346}
3447
3548func (cmd * ValidatorCommand ) HelpDesc () (string , string ) {
@@ -38,11 +51,12 @@ func (cmd *ValidatorCommand) HelpDesc() (string, string) {
3851
3952func (cmd * ValidatorCommand ) HelpExample () string {
4053 return `% {{.ParentCommand}} {{.Command}} "https://www.bart.gov/dev/schedules/google_transit.zip"
41- % {{.ParentCommand}} {{.Command}} -o - --include-entities "http://developer.trimet.org/schedule/gtfs.zip"`
54+ % {{.ParentCommand}} {{.Command}} -o - --include-entities "http://developer.trimet.org/schedule/gtfs.zip"
55+ % {{.ParentCommand}} {{.Command}} --dmfr feeds/wmata.com.dmfr.json --feed-id f-dqcq-wmata~rail --secrets secrets.json`
4256}
4357
4458func (cmd * ValidatorCommand ) HelpArgs () string {
45- return "[flags] <reader>"
59+ return "[flags] [ <reader>] "
4660}
4761
4862// shouldShowLogs returns true if logs should be displayed
@@ -65,17 +79,52 @@ func (cmd *ValidatorCommand) AddFlags(fl *pflag.FlagSet) {
6579 fl .StringSliceVar (& cmd .rtFiles , "rt" , nil , "Include GTFS-RT proto message in validation report" )
6680 fl .IntVar (& cmd .Options .ErrorLimit , "error-limit" , 1000 , "Max number of detailed errors per error group" )
6781 fl .StringSliceVar (& cmd .errorThresholds , "error-threshold" , nil , "Fail validation if file exceeds error percentage; format: 'filename:percent' or '*:percent' for default (e.g., 'stops.txt:5' or '*:10')" )
82+ fl .StringVar (& cmd .SecretsFile , "secrets" , "" , "Path to DMFR Secrets file (requires --dmfr and --feed-id)" )
83+ fl .StringArrayVar (& cmd .SecretEnv , "secret-env" , nil , "Specify secret from environment variable as feed_id:ENV_VAR or file.json:ENV_VAR (requires --dmfr and --feed-id)" )
84+ fl .StringVar (& cmd .DMFRFile , "dmfr" , "" , "DMFR file providing feed URL and authorization config; used with --feed-id" )
85+ fl .StringVar (& cmd .FeedID , "feed-id" , "" , "Feed onestop ID for DMFR and secret lookup (requires --dmfr)" )
86+ fl .StringVar (& cmd .URLType , "url-type" , "static_current" , "URL type in DMFR feed.urls to validate" )
87+ fl .BoolVar (& cmd .AllowFTPFetch , "allow-ftp-fetch" , false , "Allow fetching from FTP urls when --dmfr is used" )
88+ fl .BoolVar (& cmd .AllowLocalFetch , "allow-local-fetch" , false , "Allow fetching from filesystem paths when --dmfr is used" )
89+ fl .BoolVar (& cmd .AllowS3Fetch , "allow-s3-fetch" , false , "Allow fetching from S3 urls when --dmfr is used" )
6890}
6991
7092func (cmd * ValidatorCommand ) Parse (args []string ) error {
7193 fl := tlcli .NewNArgs (args )
72- if fl .NArg () < 1 {
73- return errors .New ("requires input reader" )
74- }
7594 if cmd .DBURL == "" {
7695 cmd .DBURL = os .Getenv ("TL_DATABASE_URL" )
7796 }
78- cmd .readerPath = fl .Arg (0 )
97+ if fl .NArg () >= 1 {
98+ cmd .readerPath = fl .Arg (0 )
99+ }
100+ if cmd .DMFRFile != "" && cmd .FeedID == "" {
101+ return errors .New ("--dmfr requires --feed-id" )
102+ }
103+ if cmd .FeedID != "" && cmd .DMFRFile == "" {
104+ return errors .New ("--feed-id requires --dmfr" )
105+ }
106+ if (cmd .SecretsFile != "" || len (cmd .SecretEnv ) > 0 ) && cmd .DMFRFile == "" {
107+ return errors .New ("--secrets and --secret-env require --dmfr and --feed-id" )
108+ }
109+ if cmd .readerPath == "" && cmd .DMFRFile == "" {
110+ return errors .New ("requires input reader or --dmfr with --feed-id" )
111+ }
112+ // Load secrets from file
113+ if cmd .SecretsFile != "" {
114+ r , err := dmfr .LoadAndParseRegistry (cmd .SecretsFile )
115+ if err != nil {
116+ return err
117+ }
118+ cmd .secrets = r .Secrets
119+ }
120+ // Parse --secret-env arguments
121+ for _ , se := range cmd .SecretEnv {
122+ secret , err := parseSecretEnv (se )
123+ if err != nil {
124+ return err
125+ }
126+ cmd .secrets = append (cmd .secrets , secret )
127+ }
79128 cmd .Options .ValidateRealtimeMessages = cmd .rtFiles
80129 cmd .Options .ExtensionDefs = cmd .extensionDefs
81130 cmd .Options .EvaluateAt = time .Now ().In (time .UTC )
@@ -102,6 +151,15 @@ func (cmd *ValidatorCommand) Parse(args []string) error {
102151}
103152
104153func (cmd * ValidatorCommand ) Run (ctx context.Context ) error {
154+ // If --dmfr is set, look up feed auth/URL and download to a temp file with auth
155+ if cmd .DMFRFile != "" {
156+ tmpfile , err := cmd .fetchWithAuth (ctx )
157+ if err != nil {
158+ return err
159+ }
160+ defer os .Remove (tmpfile )
161+ cmd .readerPath = tmpfile
162+ }
105163 // Only log if not outputting JSON to stdout
106164 if cmd .shouldShowLogs () {
107165 log .For (ctx ).Info ().Msgf ("Validating: %s" , cmd .readerPath )
@@ -161,3 +219,89 @@ func (cmd *ValidatorCommand) Run(ctx context.Context) error {
161219 }
162220 return nil
163221}
222+
223+ // fetchWithAuth resolves feed URL and authorization from the DMFR file, downloads
224+ // the feed with auth applied (if configured), and returns the path to a temp file
225+ // that the caller is responsible for removing.
226+ func (cmd * ValidatorCommand ) fetchWithAuth (ctx context.Context ) (string , error ) {
227+ reg , err := dmfr .LoadAndParseRegistry (cmd .DMFRFile )
228+ if err != nil {
229+ return "" , err
230+ }
231+ var feed * dmfr.Feed
232+ for i := range reg .Feeds {
233+ if reg .Feeds [i ].FeedID == cmd .FeedID {
234+ feed = & reg .Feeds [i ]
235+ break
236+ }
237+ }
238+ if feed == nil {
239+ return "" , fmt .Errorf ("feed %q not found in %s" , cmd .FeedID , cmd .DMFRFile )
240+ }
241+ // LoadAndParseRegistry doesn't populate feed.File; set it so secrets that
242+ // match by filename (e.g. {"filename": "wmata.com.dmfr.json"}) resolve.
243+ feed .File = filepath .Base (cmd .DMFRFile )
244+ // Determine URL: explicit positional arg overrides DMFR lookup
245+ feedURL := cmd .readerPath
246+ if feedURL == "" {
247+ feedURL , err = urlForType (feed .URLs , cmd .URLType )
248+ if err != nil {
249+ return "" , err
250+ }
251+ }
252+ if feedURL == "" {
253+ return "" , fmt .Errorf ("no %s URL found for feed %q" , cmd .URLType , cmd .FeedID )
254+ }
255+ var reqOpts []request.RequestOption
256+ if cmd .AllowFTPFetch {
257+ reqOpts = append (reqOpts , request .WithAllowFTP )
258+ }
259+ if cmd .AllowLocalFetch {
260+ reqOpts = append (reqOpts , request .WithAllowLocal )
261+ }
262+ if cmd .AllowS3Fetch {
263+ reqOpts = append (reqOpts , request .WithAllowS3 )
264+ }
265+ if feed .Authorization .Type != "" {
266+ secret , err := feed .MatchSecrets (cmd .secrets , cmd .URLType )
267+ if err != nil {
268+ return "" , fmt .Errorf ("authorization %q configured for feed %q but %w" , feed .Authorization .Type , cmd .FeedID , err )
269+ }
270+ reqOpts = append (reqOpts , request .WithAuth (secret , feed .Authorization ))
271+ }
272+ if cmd .shouldShowLogs () {
273+ log .For (ctx ).Info ().Str ("feed_id" , cmd .FeedID ).Str ("url" , feedURL ).Str ("auth_type" , feed .Authorization .Type ).Msg ("Fetching feed" )
274+ }
275+ tmpfile , fr , err := request .AuthenticatedRequestDownload (ctx , feedURL , reqOpts ... )
276+ if err != nil {
277+ if tmpfile != "" {
278+ os .Remove (tmpfile )
279+ }
280+ return "" , err
281+ }
282+ if fr .FetchError != nil {
283+ if tmpfile != "" {
284+ os .Remove (tmpfile )
285+ }
286+ return "" , fmt .Errorf ("fetch failed: %w" , fr .FetchError )
287+ }
288+ return tmpfile , nil
289+ }
290+
291+ func urlForType (urls dmfr.FeedUrls , urlType string ) (string , error ) {
292+ switch urlType {
293+ case "" , "static_current" :
294+ return urls .StaticCurrent , nil
295+ case "realtime_trip_updates" :
296+ return urls .RealtimeTripUpdates , nil
297+ case "realtime_vehicle_positions" :
298+ return urls .RealtimeVehiclePositions , nil
299+ case "realtime_alerts" :
300+ return urls .RealtimeAlerts , nil
301+ case "gbfs_auto_discovery" :
302+ return urls .GbfsAutoDiscovery , nil
303+ case "mds_provider" :
304+ return urls .MdsProvider , nil
305+ }
306+ return "" , fmt .Errorf ("unsupported --url-type %q" , urlType )
307+ }
0 commit comments