Skip to content

Commit 1c08c69

Browse files
Merge pull request #2 from OneBusAway/json-ui-output-mode
Add JSON-for-UI output mode with published JSON Schema
2 parents cec626b + c8ee9d2 commit 1c08c69

14 files changed

Lines changed: 2134 additions & 32 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ The flow is **config → prepare (fetch) → checks → report**:
3232
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.
3333
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).
3434
3. **`validator`** — the engine. `validator.Run()` calls `prepare()`, then runs every check.
35-
4. **`report`** — renders a `Report` as grouped text (`WriteText`) or indented JSON (`WriteJSON`).
35+
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.
3636

3737
`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.
3838

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,23 @@ to the `agencyId` the OBA server exposes; unmapped agencies default to identity.
4141
```go
4242
cfg, _ := config.Load("config.json") // or a raw JSON string
4343
rep, _ := validator.Run(ctx, cfg)
44-
report.WriteText(os.Stdout, rep) // or report.WriteJSON(os.Stdout, rep)
44+
report.WriteText(os.Stdout, rep) // or report.WriteJSON(os.Stdout, rep, cfg)
4545
os.Exit(rep.ExitCode())
4646
```
4747

48+
## JSON output
49+
50+
`--json` emits a single structured document to stdout, designed for building a UI
51+
visualization. It contains `meta` (run inputs — never the apiKey), `summary`
52+
(verdict + status counts), and `groups` (a `server` group plus one per data
53+
source, each with its results). On failure before a report is produced, a
54+
an object like `{ "schemaVersion": "1.0", "error": "..." }` is emitted to stdout and the process exits 2.
55+
56+
The full contract is published as a JSON Schema (draft 2020-12) at
57+
[`schema/oba-validator-report.schema.json`](schema/oba-validator-report.schema.json).
58+
This is the recommended format for the Render one-off-job workflow: the job
59+
prints the document to stdout and the caller reads it from the job output.
60+
4861
## Development
4962

5063
make build # compile to bin/oba-validator

cmd/oba-validator/main.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,32 @@ import (
66
"fmt"
77
"io"
88
"os"
9+
"regexp"
10+
"strings"
911

1012
"github.com/onebusaway/oba-validator/config"
1113
"github.com/onebusaway/oba-validator/report"
1214
"github.com/onebusaway/oba-validator/validator"
1315
)
1416

17+
// apiKeyInJSON matches an "apiKey" string field in a (possibly malformed) JSON
18+
// argument so its value can be scrubbed from error output.
19+
var apiKeyInJSON = regexp.MustCompile(`"apiKey"\s*:\s*"((?:\\.|[^"\\])*)"`)
20+
21+
// redactionKey returns the apiKey to scrub from a config-load error. config.Load
22+
// can fail before it parses the key and echo the raw argument (and thus an inline
23+
// apiKey) into its error — either when a raw-JSON argument that does not start
24+
// with '{' is misread as a file path (the os.ReadFile error wraps the input), or
25+
// when a malformed object fails to parse. config.Load returns an empty Config in
26+
// both cases, so prefer a key sniffed straight from the argument, falling back to
27+
// the environment.
28+
func redactionKey(arg string) string {
29+
if m := apiKeyInJSON.FindStringSubmatch(arg); m != nil && m[1] != "" {
30+
return m[1]
31+
}
32+
return os.Getenv("ONEBUSAWAY_API_KEY")
33+
}
34+
1535
type overrides struct {
1636
jsonOut bool
1737
sampleSize int
@@ -68,20 +88,41 @@ func run(args []string, stdout, stderr io.Writer) int {
6888

6989
cfg, err := config.Load(fs.Arg(0))
7090
if err != nil {
71-
fmt.Fprintln(stderr, "config error:", err)
91+
key := redactionKey(fs.Arg(0))
92+
if o.jsonOut {
93+
if werr := report.WriteErrorJSON(stdout, err.Error(), key); werr != nil {
94+
fmt.Fprintln(stderr, "output error:", werr)
95+
}
96+
} else {
97+
msg := err.Error()
98+
if key != "" {
99+
msg = strings.ReplaceAll(msg, key, "***")
100+
}
101+
fmt.Fprintln(stderr, "config error:", msg)
102+
}
72103
return 2
73104
}
74105
applyOverrides(&cfg, o)
75106

76107
rep, err := validator.Run(context.Background(), cfg)
77108
if err != nil {
78-
fmt.Fprintln(stderr, "run error:", err)
109+
if o.jsonOut {
110+
if werr := report.WriteErrorJSON(stdout, err.Error(), cfg.APIKey); werr != nil {
111+
fmt.Fprintln(stderr, "output error:", werr)
112+
}
113+
} else {
114+
msg := err.Error()
115+
if cfg.APIKey != "" {
116+
msg = strings.ReplaceAll(msg, cfg.APIKey, "***")
117+
}
118+
fmt.Fprintln(stderr, "run error:", msg)
119+
}
79120
return 2
80121
}
81122

82123
var werr error
83124
if o.jsonOut {
84-
werr = report.WriteJSON(stdout, rep)
125+
werr = report.WriteJSON(stdout, rep, cfg)
85126
} else {
86127
werr = report.WriteText(stdout, rep)
87128
}

cmd/oba-validator/main_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ package main
22

33
import (
44
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
59
"testing"
610

711
"github.com/onebusaway/oba-validator/config"
@@ -36,3 +40,84 @@ func TestUsageWhenTooManyArgs(t *testing.T) {
3640
t.Error("expected usage on stderr")
3741
}
3842
}
43+
44+
func TestRunJSONConfigErrorEmitsErrorJSON(t *testing.T) {
45+
var stdout, stderr bytes.Buffer
46+
code := run([]string{"oba-validator", "--json", `{"dataSources":[]}`}, &stdout, &stderr)
47+
if code != 2 {
48+
t.Fatalf("exit=%d want 2", code)
49+
}
50+
var ed struct {
51+
SchemaVersion string `json:"schemaVersion"`
52+
Error string `json:"error"`
53+
}
54+
if err := json.Unmarshal(stdout.Bytes(), &ed); err != nil {
55+
t.Fatalf("stdout not JSON: %v\n%s", err, stdout.String())
56+
}
57+
if ed.Error == "" || ed.SchemaVersion == "" {
58+
t.Errorf("missing fields in error doc: %s", stdout.String())
59+
}
60+
}
61+
62+
func TestRunJSONOutputShape(t *testing.T) {
63+
obaSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
64+
w.Header().Set("Content-Type", "application/json")
65+
switch {
66+
case strings.Contains(r.URL.Path, "current-time"):
67+
w.Write([]byte(`{"data":{"entry":{"time":1716000000000}}}`))
68+
case strings.Contains(r.URL.Path, "agencies-with-coverage"):
69+
w.Write([]byte(`{"data":{"list":[],"references":{"agencies":[]}}}`))
70+
default:
71+
w.Write([]byte(`{"data":{"list":[],"entry":{"arrivalsAndDepartures":[]}}}`))
72+
}
73+
}))
74+
defer obaSrv.Close()
75+
feedSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76+
w.Write([]byte{}) // empty payload -> prep error recorded, run still completes
77+
}))
78+
defer feedSrv.Close()
79+
80+
cfg := `{"obaServerURL":"` + obaSrv.URL + `","apiKey":"test","dataSources":[{"staticGtfsFeedURL":"` + feedSrv.URL + `/gtfs.zip"}]}`
81+
var stdout, stderr bytes.Buffer
82+
run([]string{"oba-validator", "--json", "--no-cache", cfg}, &stdout, &stderr)
83+
84+
var doc map[string]json.RawMessage
85+
if err := json.Unmarshal(stdout.Bytes(), &doc); err != nil {
86+
t.Fatalf("stdout not JSON: %v\n%s", err, stdout.String())
87+
}
88+
for _, k := range []string{"schemaVersion", "meta", "summary", "groups"} {
89+
if _, ok := doc[k]; !ok {
90+
t.Errorf("missing key %q in output:\n%s", k, stdout.String())
91+
}
92+
}
93+
}
94+
95+
func TestRunJSONConfigErrorRedactsInlineAPIKey(t *testing.T) {
96+
t.Setenv("ONEBUSAWAY_API_KEY", "")
97+
var stdout, stderr bytes.Buffer
98+
code := run([]string{"oba-validator", "--json", `[{"obaServerURL":"https://x","apiKey":"SUPER-SECRET-KEY"}]`}, &stdout, &stderr)
99+
if code != 2 {
100+
t.Fatalf("exit=%d want 2", code)
101+
}
102+
if strings.Contains(stdout.String(), "SUPER-SECRET-KEY") {
103+
t.Errorf("apiKey leaked to stdout:\n%s", stdout.String())
104+
}
105+
var ed struct {
106+
Error string `json:"error"`
107+
}
108+
if err := json.Unmarshal(stdout.Bytes(), &ed); err != nil {
109+
t.Fatalf("stdout not JSON: %v\n%s", err, stdout.String())
110+
}
111+
if ed.Error == "" {
112+
t.Errorf("expected an error message: %s", stdout.String())
113+
}
114+
}
115+
116+
func TestRunTextConfigErrorRedactsInlineAPIKey(t *testing.T) {
117+
t.Setenv("ONEBUSAWAY_API_KEY", "")
118+
var stdout, stderr bytes.Buffer
119+
run([]string{"oba-validator", `[{"obaServerURL":"https://x","apiKey":"SUPER-SECRET-KEY"}]`}, &stdout, &stderr)
120+
if strings.Contains(stderr.String(), "SUPER-SECRET-KEY") {
121+
t.Errorf("apiKey leaked to stderr:\n%s", stderr.String())
122+
}
123+
}

0 commit comments

Comments
 (0)