Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The flow is **config → prepare (fetch) → checks → report**:
1. **`config`** — `config.Load()` accepts a file path *or* a raw JSON string (auto-detected by a leading `{`). Applies defaults, validates required fields, and reads `apiKey` from `ONEBUSAWAY_API_KEY` if absent.
2. **`feeds`** — fetching + parsing. `Fetcher` downloads feeds; static GTFS goes through an on-disk **conditional-GET `Cache`** (ETag/Last-Modified, atomic body-then-meta writes), realtime feeds are always fetched fresh. `ParsedStatic` wraps go-gtfs's `Static` with the lookup indexes checks need (agency IDs/names, raw trip→agency, raw route→agency).
3. **`validator`** — the engine. `validator.Run()` calls `prepare()`, then runs every check.
4. **`report`** — renders a `Report` as grouped text (`WriteText`) or indented JSON (`WriteJSON`).
4. **`report`** — renders a `Report` as grouped text (`WriteText`) or, via `WriteJSON`, a UI-oriented JSON `Document` (meta + summary + grouped results; schema at `schema/oba-validator-report.schema.json`). `WriteErrorJSON` emits the error variant. The `Document` view model is built by the pure `BuildDocument(report, config, now)` so output is deterministic in tests.

`prepare()` (`validator/validator.go`) builds the shared `ValidationContext`: it constructs the OBA SDK client, fetches `AgenciesWithCoverage` once, and **fans out concurrently** (bounded by `MaxConcurrency`, default 4) to download/parse each data source's feeds into a `SourceContext`. A per-feed fetch/parse failure is recorded in `SourceContext.PrepErrors[feedName]` rather than aborting the run — checks inspect that map and decide severity themselves.

Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,23 @@ to the `agencyId` the OBA server exposes; unmapped agencies default to identity.
```go
cfg, _ := config.Load("config.json") // or a raw JSON string
rep, _ := validator.Run(ctx, cfg)
report.WriteText(os.Stdout, rep) // or report.WriteJSON(os.Stdout, rep)
report.WriteText(os.Stdout, rep) // or report.WriteJSON(os.Stdout, rep, cfg)
os.Exit(rep.ExitCode())
```

## JSON output

`--json` emits a single structured document to stdout, designed for building a UI
visualization. It contains `meta` (run inputs — never the apiKey), `summary`
(verdict + status counts), and `groups` (a `server` group plus one per data
source, each with its results). On failure before a report is produced, a
an object like `{ "schemaVersion": "1.0", "error": "..." }` is emitted to stdout and the process exits 2.

The full contract is published as a JSON Schema (draft 2020-12) at
[`schema/oba-validator-report.schema.json`](schema/oba-validator-report.schema.json).
This is the recommended format for the Render one-off-job workflow: the job
prints the document to stdout and the caller reads it from the job output.

## Development

make build # compile to bin/oba-validator
Expand Down
47 changes: 44 additions & 3 deletions cmd/oba-validator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,32 @@
"fmt"
"io"
"os"
"regexp"
"strings"

"github.com/onebusaway/oba-validator/config"
"github.com/onebusaway/oba-validator/report"
"github.com/onebusaway/oba-validator/validator"
)

// apiKeyInJSON matches an "apiKey" string field in a (possibly malformed) JSON
// argument so its value can be scrubbed from error output.
var apiKeyInJSON = regexp.MustCompile(`"apiKey"\s*:\s*"((?:\\.|[^"\\])*)"`)

// redactionKey returns the apiKey to scrub from a config-load error. config.Load
// can fail before it parses the key and echo the raw argument (and thus an inline
// apiKey) into its error — either when a raw-JSON argument that does not start
// with '{' is misread as a file path (the os.ReadFile error wraps the input), or
// when a malformed object fails to parse. config.Load returns an empty Config in
// both cases, so prefer a key sniffed straight from the argument, falling back to
// the environment.
func redactionKey(arg string) string {
if m := apiKeyInJSON.FindStringSubmatch(arg); m != nil && m[1] != "" {
return m[1]
}
return os.Getenv("ONEBUSAWAY_API_KEY")
}

type overrides struct {
jsonOut bool
sampleSize int
Expand Down Expand Up @@ -43,7 +63,7 @@
}
}

func run(args []string, stdout, stderr io.Writer) int {

Check failure on line 66 in cmd/oba-validator/main.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 25 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=OneBusAway_server-validator&issues=AZ5g9HO8cVnEFZITNGO_&open=AZ5g9HO8cVnEFZITNGO_&pullRequest=2
fs := flag.NewFlagSet("oba-validator", flag.ContinueOnError)
fs.SetOutput(stderr)
var o overrides
Expand All @@ -68,20 +88,41 @@

cfg, err := config.Load(fs.Arg(0))
if err != nil {
fmt.Fprintln(stderr, "config error:", err)
key := redactionKey(fs.Arg(0))
if o.jsonOut {
if werr := report.WriteErrorJSON(stdout, err.Error(), key); werr != nil {
fmt.Fprintln(stderr, "output error:", werr)

Check failure on line 94 in cmd/oba-validator/main.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "output error:" 3 times.

See more on https://sonarcloud.io/project/issues?id=OneBusAway_server-validator&issues=AZ5g9HO7cVnEFZITNGO-&open=AZ5g9HO7cVnEFZITNGO-&pullRequest=2
}
} else {
msg := err.Error()
if key != "" {
msg = strings.ReplaceAll(msg, key, "***")
}
fmt.Fprintln(stderr, "config error:", msg)
}
return 2
}
applyOverrides(&cfg, o)

rep, err := validator.Run(context.Background(), cfg)
if err != nil {
fmt.Fprintln(stderr, "run error:", err)
if o.jsonOut {
if werr := report.WriteErrorJSON(stdout, err.Error(), cfg.APIKey); werr != nil {
fmt.Fprintln(stderr, "output error:", werr)
}
} else {
msg := err.Error()
if cfg.APIKey != "" {
msg = strings.ReplaceAll(msg, cfg.APIKey, "***")
}
fmt.Fprintln(stderr, "run error:", msg)
}
return 2
}

var werr error
if o.jsonOut {
werr = report.WriteJSON(stdout, rep)
werr = report.WriteJSON(stdout, rep, cfg)
} else {
werr = report.WriteText(stdout, rep)
}
Expand Down
85 changes: 85 additions & 0 deletions cmd/oba-validator/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package main

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/onebusaway/oba-validator/config"
Expand Down Expand Up @@ -36,3 +40,84 @@ func TestUsageWhenTooManyArgs(t *testing.T) {
t.Error("expected usage on stderr")
}
}

func TestRunJSONConfigErrorEmitsErrorJSON(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run([]string{"oba-validator", "--json", `{"dataSources":[]}`}, &stdout, &stderr)
if code != 2 {
t.Fatalf("exit=%d want 2", code)
}
var ed struct {
SchemaVersion string `json:"schemaVersion"`
Error string `json:"error"`
}
if err := json.Unmarshal(stdout.Bytes(), &ed); err != nil {
t.Fatalf("stdout not JSON: %v\n%s", err, stdout.String())
}
if ed.Error == "" || ed.SchemaVersion == "" {
t.Errorf("missing fields in error doc: %s", stdout.String())
}
}

func TestRunJSONOutputShape(t *testing.T) {
obaSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(r.URL.Path, "current-time"):
w.Write([]byte(`{"data":{"entry":{"time":1716000000000}}}`))
case strings.Contains(r.URL.Path, "agencies-with-coverage"):
w.Write([]byte(`{"data":{"list":[],"references":{"agencies":[]}}}`))
default:
w.Write([]byte(`{"data":{"list":[],"entry":{"arrivalsAndDepartures":[]}}}`))
}
}))
defer obaSrv.Close()
feedSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte{}) // empty payload -> prep error recorded, run still completes
}))
defer feedSrv.Close()

cfg := `{"obaServerURL":"` + obaSrv.URL + `","apiKey":"test","dataSources":[{"staticGtfsFeedURL":"` + feedSrv.URL + `/gtfs.zip"}]}`
var stdout, stderr bytes.Buffer
run([]string{"oba-validator", "--json", "--no-cache", cfg}, &stdout, &stderr)

var doc map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &doc); err != nil {
t.Fatalf("stdout not JSON: %v\n%s", err, stdout.String())
}
for _, k := range []string{"schemaVersion", "meta", "summary", "groups"} {
if _, ok := doc[k]; !ok {
t.Errorf("missing key %q in output:\n%s", k, stdout.String())
}
}
}

func TestRunJSONConfigErrorRedactsInlineAPIKey(t *testing.T) {
t.Setenv("ONEBUSAWAY_API_KEY", "")
var stdout, stderr bytes.Buffer
code := run([]string{"oba-validator", "--json", `[{"obaServerURL":"https://x","apiKey":"SUPER-SECRET-KEY"}]`}, &stdout, &stderr)
if code != 2 {
t.Fatalf("exit=%d want 2", code)
}
if strings.Contains(stdout.String(), "SUPER-SECRET-KEY") {
t.Errorf("apiKey leaked to stdout:\n%s", stdout.String())
}
var ed struct {
Error string `json:"error"`
}
if err := json.Unmarshal(stdout.Bytes(), &ed); err != nil {
t.Fatalf("stdout not JSON: %v\n%s", err, stdout.String())
}
if ed.Error == "" {
t.Errorf("expected an error message: %s", stdout.String())
}
}

func TestRunTextConfigErrorRedactsInlineAPIKey(t *testing.T) {
t.Setenv("ONEBUSAWAY_API_KEY", "")
var stdout, stderr bytes.Buffer
run([]string{"oba-validator", `[{"obaServerURL":"https://x","apiKey":"SUPER-SECRET-KEY"}]`}, &stdout, &stderr)
if strings.Contains(stderr.String(), "SUPER-SECRET-KEY") {
t.Errorf("apiKey leaked to stderr:\n%s", stderr.String())
}
}
Loading