Skip to content

Commit c29e95a

Browse files
drewdaclaude
andcommitted
Support feed authorization in validate command
Add --dmfr, --feed-id, --secrets, --secret-env, --url-type flags to look up a feed's URL and auth config from a DMFR file and download with credentials applied before validating. Mirrors the auth pattern used by fetch. Enables validating auth-protected feeds from Atlas without manual credential handling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 18eb352 commit c29e95a

1 file changed

Lines changed: 150 additions & 6 deletions

File tree

cmds/validator_cmd.go

Lines changed: 150 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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

3548
func (cmd *ValidatorCommand) HelpDesc() (string, string) {
@@ -38,11 +51,12 @@ func (cmd *ValidatorCommand) HelpDesc() (string, string) {
3851

3952
func (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

4458
func (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

7092
func (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

104153
func (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

Comments
 (0)