package forecast import ( "context" "fmt" "io" "net/http" "net/url" "os" ) // Fetcher returns the CSV bytes for a forecast source. The HTTP fetcher pulls a public export URL; the // file fetcher reads an operator provided export, because the GSA tool export is a download from a gated // dashboard rather than a clean API. The caller closes the reader. type Fetcher func(ctx context.Context) (io.ReadCloser, error) // HTTPFetcher fetches a CSV export from a URL. Error messages name only the host, never the full URL, so // a credential embedded in the configured URL (https://[credentials removed]@host/forecast.csv) cannot leak into // a log on a fetch failure. func HTTPFetcher(client *http.Client, rawURL string) Fetcher { return func(ctx context.Context) (io.ReadCloser, error) { host := "unknown host" if u, err := url.Parse(rawURL); err == nil && u.Host != "" { host = u.Host } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { // The underlying error can echo the full URL, so report only the host. return nil, fmt.Errorf("forecast: build request for %s failed", host) } req.Header.Set("Accept", "text/csv") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("forecast: fetch %s: %w", host, err) } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, fmt.Errorf("forecast: fetch %s: status %d", host, resp.StatusCode) } return resp.Body, nil } } // FileFetcher reads a CSV export from a local path, the path an operator points at after downloading the // export from the gated dashboard. func FileFetcher(path string) Fetcher { return func(_ context.Context) (io.ReadCloser, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("forecast: open %s: %w", path, err) } return f, nil } } // Source is a named forecast feed: a Fetcher plus a Mapping. It is the generic base every agency // forecast reuses, so adding a new agency is a Source value (its export location and its column mapping), // not new parsing code. type Source struct { name string fetch Fetcher mapping Mapping } // NewSource builds a forecast source. func NewSource(name string, fetch Fetcher, mapping Mapping) *Source { return &Source{name: name, fetch: fetch, mapping: mapping} } // Name is the provenance label for this forecast feed. func (s *Source) Name() string { return s.name } // Records fetches the export and parses it, returning the normalized records and the count of rows it // skipped as unparseable. func (s *Source) Records(ctx context.Context) ([]Record, int, error) { body, err := s.fetch(ctx) if err != nil { return nil, 0, err } defer body.Close() return Parse(body, s.mapping) }