Repository navigation
Add a parser for obacloud's bundle-inputs manifest #1814
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
26a74a4
Export static feed validator and URL check
aaronbrethorst aba99f3
Add bundle-inputs.json manifest parser
aaronbrethorst 42385f1
Fetch bundle-inputs manifest from URL or path
aaronbrethorst 776178a
Harden manifest parsing and local reads
aaronbrethorst 8217695
Reject a negative byteSize in bundle inputs
aaronbrethorst File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| // Package bundleinputs reads the bundle-inputs.json manifest that obacloud | ||
| // publishes to list the static GTFS feeds a server should load. | ||
| package bundleinputs | ||
|
|
||
| import ( | ||
| "encoding/hex" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
|
|
||
| "maglev.onebusaway.org/internal/appconf" | ||
| ) | ||
|
|
||
| // SupportedVersion is the only manifest version Parse accepts. | ||
| const SupportedVersion = 1 | ||
|
|
||
| // sha256HexLength is the length of a SHA-256 digest written as hex. | ||
| const sha256HexLength = 64 | ||
|
|
||
| // Manifest is a parsed bundle-inputs.json file. | ||
| type Manifest struct { | ||
| Version int `json:"version"` | ||
| Feeds []Feed `json:"feeds"` | ||
| // StopConsolidationURL is optional and empty when the manifest has none. | ||
| StopConsolidationURL string `json:"stopConsolidationUrl"` | ||
| } | ||
|
|
||
| // Feed is one static feed in a manifest. Feed order is load order. | ||
| type Feed struct { | ||
| ID string `json:"id"` | ||
| Name string `json:"name"` | ||
| DefaultAgencyID string `json:"defaultAgencyId"` | ||
| URL string `json:"url"` | ||
| // ByteSize and SHA256 describe the published zip, for checking the | ||
| // download. Nothing checks them yet. | ||
| ByteSize int64 `json:"byteSize"` | ||
| SHA256 string `json:"sha256"` | ||
| } | ||
|
|
||
| // Parse decodes and validates a manifest. Unknown keys are ignored. | ||
| func Parse(r io.Reader) (*Manifest, error) { | ||
| var manifest Manifest | ||
| decoder := json.NewDecoder(r) | ||
| if err := decoder.Decode(&manifest); err != nil { | ||
| return nil, fmt.Errorf("invalid bundle inputs JSON: %w", err) | ||
| } | ||
| if _, err := decoder.Token(); err != io.EOF { | ||
| return nil, fmt.Errorf("invalid bundle inputs JSON: unexpected data after the manifest") | ||
| } | ||
| if err := manifest.validate(); err != nil { | ||
| return nil, err | ||
| } | ||
| return &manifest, nil | ||
| } | ||
|
|
||
| // StaticFeeds returns the manifest feeds as static feed config, in order. | ||
| func (m *Manifest) StaticFeeds() []appconf.GtfsStaticFeed { | ||
| feeds := make([]appconf.GtfsStaticFeed, 0, len(m.Feeds)) | ||
| for _, feed := range m.Feeds { | ||
| feeds = append(feeds, appconf.GtfsStaticFeed{ | ||
| ID: feed.ID, | ||
| URL: feed.URL, | ||
| DefaultAgencyID: feed.DefaultAgencyID, | ||
| }) | ||
| } | ||
| return feeds | ||
| } | ||
|
|
||
| // manifestFeedsKey names the manifest feed list in shared validation errors. | ||
| const manifestFeedsKey = "bundle inputs feeds" | ||
|
|
||
| // validate applies the manifest format rules, then the checks every static | ||
| // feed list gets. | ||
| func (m *Manifest) validate() error { | ||
| if m.Version != SupportedVersion { | ||
| return fmt.Errorf("unsupported bundle inputs version %d, want %d", m.Version, SupportedVersion) | ||
| } | ||
| if len(m.Feeds) == 0 { | ||
| return fmt.Errorf("bundle inputs feeds cannot be empty") | ||
| } | ||
| if m.StopConsolidationURL != "" && !appconf.IsHTTPURL(m.StopConsolidationURL) { | ||
| return fmt.Errorf("stopConsolidationUrl %q must be an http:// or https:// URL", m.StopConsolidationURL) | ||
| } | ||
| for i, feed := range m.Feeds { | ||
| if err := feed.validate(); err != nil { | ||
| return fmt.Errorf("bundle inputs feeds[%d]: %w", i, err) | ||
| } | ||
| } | ||
| return appconf.ValidateStaticFeeds(m.StaticFeeds(), manifestFeedsKey) | ||
| } | ||
|
|
||
| // validate applies the manifest format rules to one feed. The url must be | ||
| // http(s): a manifest may come from a remote server, so it must not name | ||
| // paths on this machine. | ||
| func (feed Feed) validate() error { | ||
| if feed.ID == "" { | ||
| return fmt.Errorf("id is required") | ||
| } | ||
| if feed.DefaultAgencyID == "" { | ||
| return fmt.Errorf("defaultAgencyId is required") | ||
| } | ||
| if !appconf.IsHTTPURL(feed.URL) { | ||
| return fmt.Errorf("url %q must be an http:// or https:// URL", feed.URL) | ||
| } | ||
| if feed.SHA256 != "" && !isSHA256Hex(feed.SHA256) { | ||
| return fmt.Errorf("sha256 must be %d hex characters", sha256HexLength) | ||
| } | ||
| if feed.ByteSize < 0 { | ||
| return fmt.Errorf("byteSize cannot be negative") | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func isSHA256Hex(value string) bool { | ||
| _, err := hex.DecodeString(value) | ||
| return err == nil && len(value) == sha256HexLength | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| package bundleinputs | ||
|
|
||
| import ( | ||
| "os" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "maglev.onebusaway.org/internal/appconf" | ||
| ) | ||
|
|
||
| const goldenManifestPath = "testdata/bundle-inputs.json" | ||
|
|
||
| func TestParse_GoldenManifest(t *testing.T) { | ||
| file, err := os.Open(goldenManifestPath) | ||
| require.NoError(t, err) | ||
| defer file.Close() | ||
|
|
||
| manifest, err := Parse(file) | ||
|
|
||
| require.NoError(t, err) | ||
| want := &Manifest{ | ||
| Version: 1, | ||
| Feeds: []Feed{ | ||
| { | ||
| ID: "metro", | ||
| Name: "King County Metro", | ||
| DefaultAgencyID: "1", | ||
| URL: "https://example.com/bundles/metro.zip", | ||
| ByteSize: 1234567, | ||
| SHA256: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", | ||
| }, | ||
| { | ||
| ID: "sound-transit", | ||
| Name: "Sound Transit", | ||
| DefaultAgencyID: "40", | ||
| URL: "https://example.com/bundles/sound-transit.zip", | ||
| }, | ||
| }, | ||
| StopConsolidationURL: "https://example.com/bundles/stop-consolidation.txt", | ||
| } | ||
| assert.Equal(t, want, manifest) | ||
|
|
||
| wantStaticFeeds := []appconf.GtfsStaticFeed{ | ||
| {ID: "metro", URL: "https://example.com/bundles/metro.zip", DefaultAgencyID: "1"}, | ||
| {ID: "sound-transit", URL: "https://example.com/bundles/sound-transit.zip", DefaultAgencyID: "40"}, | ||
| } | ||
| assert.Equal(t, wantStaticFeeds, manifest.StaticFeeds()) | ||
| } | ||
|
|
||
| func TestParse_Validation(t *testing.T) { | ||
| const validSHA256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" | ||
| feedA := `{"id": "a", "defaultAgencyId": "1", "url": "https://example.com/a.zip"}` | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| manifest string | ||
| wantErr string | ||
| }{ | ||
| { | ||
| name: "minimal manifest without consolidation url", | ||
| manifest: `{"version": 1, "feeds": [` + feedA + `]}`, | ||
| }, | ||
| { | ||
| name: "uppercase sha256 is accepted", | ||
| manifest: `{"version": 1, "feeds": [{"id": "a", "defaultAgencyId": "1", "url": "https://example.com/a.zip", "sha256": "` + strings.ToUpper(validSHA256) + `"}]}`, | ||
| }, | ||
| { | ||
| name: "malformed JSON", | ||
| manifest: `{"version": 1,`, | ||
| wantErr: "invalid bundle inputs JSON", | ||
| }, | ||
| { | ||
| name: "trailing data after the manifest", | ||
| manifest: `{"version": 1, "feeds": [` + feedA + `]} {}`, | ||
| wantErr: "unexpected data after the manifest", | ||
| }, | ||
| { | ||
| name: "missing version", | ||
| manifest: `{"feeds": [` + feedA + `]}`, | ||
| wantErr: "unsupported bundle inputs version 0", | ||
| }, | ||
| { | ||
| name: "future version", | ||
| manifest: `{"version": 2, "feeds": [` + feedA + `]}`, | ||
| wantErr: "unsupported bundle inputs version 2", | ||
| }, | ||
| { | ||
| name: "no feeds", | ||
| manifest: `{"version": 1, "feeds": []}`, | ||
| wantErr: "feeds cannot be empty", | ||
| }, | ||
| { | ||
| name: "missing id", | ||
| manifest: `{"version": 1, "feeds": [{"defaultAgencyId": "1", "url": "https://example.com/a.zip"}]}`, | ||
| wantErr: "feeds[0]: id is required", | ||
| }, | ||
| { | ||
| name: "missing url", | ||
| manifest: `{"version": 1, "feeds": [{"id": "a", "defaultAgencyId": "1"}]}`, | ||
| wantErr: `feeds[0]: url "" must be an http:// or https:// URL`, | ||
| }, | ||
| { | ||
| name: "local path url", | ||
| manifest: `{"version": 1, "feeds": [{"id": "a", "defaultAgencyId": "1", "url": "/etc/passwd"}]}`, | ||
| wantErr: "must be an http:// or https:// URL", | ||
| }, | ||
| { | ||
| name: "missing defaultAgencyId", | ||
| manifest: `{"version": 1, "feeds": [{"id": "a", "url": "https://example.com/a.zip"}]}`, | ||
| wantErr: "feeds[0]: defaultAgencyId is required", | ||
| }, | ||
| { | ||
| name: "defaultAgencyId with underscore", | ||
| manifest: `{"version": 1, "feeds": [{"id": "a", "defaultAgencyId": "1_2", "url": "https://example.com/a.zip"}]}`, | ||
| wantErr: `bundle inputs feeds "a": default-agency-id "1_2" must not contain '_'`, | ||
| }, | ||
| { | ||
| name: "duplicate id", | ||
| manifest: `{"version": 1, "feeds": [` + feedA + `, {"id": "a", "defaultAgencyId": "2", "url": "https://example.com/b.zip"}]}`, | ||
| wantErr: `duplicate bundle inputs feeds id: "a"`, | ||
| }, | ||
| { | ||
| name: "duplicate defaultAgencyId fails the shared static feed validator", | ||
| manifest: `{"version": 1, "feeds": [` + feedA + `, {"id": "b", "defaultAgencyId": "1", "url": "https://example.com/b.zip"}]}`, | ||
| wantErr: `duplicate bundle inputs feeds default-agency-id: "1"`, | ||
| }, | ||
| { | ||
| name: "short sha256", | ||
| manifest: `{"version": 1, "feeds": [{"id": "a", "defaultAgencyId": "1", "url": "https://example.com/a.zip", "sha256": "abc123"}]}`, | ||
| wantErr: "sha256 must be 64 hex characters", | ||
| }, | ||
| { | ||
| name: "non-hex sha256", | ||
| manifest: `{"version": 1, "feeds": [{"id": "a", "defaultAgencyId": "1", "url": "https://example.com/a.zip", "sha256": "` + strings.Repeat("z", 64) + `"}]}`, | ||
| wantErr: "sha256 must be 64 hex characters", | ||
| }, | ||
| { | ||
| name: "negative byteSize", | ||
| manifest: `{"version": 1, "feeds": [{"id": "a", "defaultAgencyId": "1", "url": "https://example.com/a.zip", "byteSize": -1}]}`, | ||
| wantErr: "byteSize cannot be negative", | ||
| }, | ||
| { | ||
| name: "local stopConsolidationUrl", | ||
| manifest: `{"version": 1, "feeds": [` + feedA + `], "stopConsolidationUrl": "stop-consolidation.txt"}`, | ||
| wantErr: "stopConsolidationUrl", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| manifest, err := Parse(strings.NewReader(tt.manifest)) | ||
|
|
||
| if tt.wantErr != "" { | ||
| require.Error(t, err) | ||
| assert.Contains(t, err.Error(), tt.wantErr) | ||
| assert.Nil(t, manifest) | ||
| return | ||
| } | ||
| require.NoError(t, err) | ||
| assert.NotEmpty(t, manifest.Feeds) | ||
| }) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit:
sha256gets a format check, butbyteSizedoesn't, so a negative value parses fine. Nothing reads it yet. Since this PR is defining the format rules, it might be worth rejectingbyteSize < 0here, rather than leaving it to the phase C download check to handle a nonsense value.Generated by Claude Code
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in b0cf517. A negative
byteSizeis now rejected next to the sha256 check.