Validate configuration source keys and overrides
This commit is contained in:
@@ -53,7 +53,11 @@ All omitted fields use their built-in defaults.
|
|||||||
| `format` | `json` | Required and must be `json`. |
|
| `format` | `json` | Required and must be `json`. |
|
||||||
|
|
||||||
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
||||||
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
|
`Stl`, US timezone abbreviations, or signed UTC offsets such as `-5`, `+0930`,
|
||||||
|
and `+09:30`. Numeric offsets require a sign, one or two hour digits, and an
|
||||||
|
optional two-digit minute component with or without a colon. Hours must be
|
||||||
|
from `00` through `23`, minutes from `00` through `59`, so the largest accepted
|
||||||
|
offset magnitude is `23:59`.
|
||||||
|
|
||||||
### `location`
|
### `location`
|
||||||
|
|
||||||
@@ -161,9 +165,10 @@ output selection, and failure handling.
|
|||||||
|
|
||||||
`missing_source.default` defaults to `warn` and accepts `error`, `warn`, or
|
`missing_source.default` defaults to `warn` and accepts `error`, `warn`, or
|
||||||
`none`. `missing_source.sources` optionally overrides that policy by source.
|
`none`. `missing_source.sources` optionally overrides that policy by source.
|
||||||
Hourly forecast data is required for generated reports. Supported optional
|
Hourly forecast data is required for generated reports and cannot have a
|
||||||
source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
|
source-specific policy. Supported optional source keys are `observations`,
|
||||||
`weather_story`, and `spc_convective_outlooks`.
|
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
|
||||||
|
`spc_convective_outlooks`; any other key is rejected.
|
||||||
|
|
||||||
### `promptkit`
|
### `promptkit`
|
||||||
|
|
||||||
@@ -218,8 +223,9 @@ derivation. Every item needs `name`, `start`, and `end`; start and end use
|
|||||||
`reports` optionally overrides a report's ordered deterministic modules and
|
`reports` optionally overrides a report's ordered deterministic modules and
|
||||||
Distributor path templates. Omit a report entry to retain its defaults.
|
Distributor path templates. Omit a report entry to retain its defaults.
|
||||||
|
|
||||||
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`; hyphens
|
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`. Keys are
|
||||||
and underscores are equivalent.
|
trimmed, case-folded to lowercase, and normalize hyphens to underscores before
|
||||||
|
lookup.
|
||||||
|
|
||||||
Each report entry can contain:
|
Each report entry can contain:
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
convectiveOutlooksEndpoint = "/outlooks/convective"
|
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||||
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
sourceSPCConvectiveOutlooks = config.MissingSourceSPCConvectiveOutlooks
|
||||||
|
|
||||||
defaultWarmupEndpoint = "/conditions/current"
|
defaultWarmupEndpoint = "/conditions/current"
|
||||||
defaultWarmupAttempts = 3
|
defaultWarmupAttempts = 3
|
||||||
@@ -168,7 +168,7 @@ type fetchedSource struct {
|
|||||||
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
||||||
var observation weatherdata.Observation
|
var observation weatherdata.Observation
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
name: "observations",
|
name: config.MissingSourceObservations,
|
||||||
endpoint: "/observations",
|
endpoint: "/observations",
|
||||||
query: queryOptions{precision: true},
|
query: queryOptions{precision: true},
|
||||||
missingMessage: "observation data is missing",
|
missingMessage: "observation data is missing",
|
||||||
@@ -186,7 +186,7 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
|||||||
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
||||||
var current weatherdata.Current
|
var current weatherdata.Current
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
name: "current",
|
name: config.MissingSourceCurrent,
|
||||||
endpoint: "/conditions/current",
|
endpoint: "/conditions/current",
|
||||||
query: queryOptions{precision: true},
|
query: queryOptions{precision: true},
|
||||||
missingMessage: "current conditions data is missing",
|
missingMessage: "current conditions data is missing",
|
||||||
@@ -227,7 +227,7 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
|||||||
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||||
var narrative weatherdata.ForecastRun
|
var narrative weatherdata.ForecastRun
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
name: "narrative",
|
name: config.MissingSourceNarrative,
|
||||||
endpoint: "/forecast/narrative",
|
endpoint: "/forecast/narrative",
|
||||||
query: queryOptions{precision: true, timezone: true},
|
query: queryOptions{precision: true, timezone: true},
|
||||||
missingMessage: "narrative forecast data is missing",
|
missingMessage: "narrative forecast data is missing",
|
||||||
@@ -244,7 +244,7 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
|
raw, source, err := b.client.fetch(ctx, config.MissingSourceAlerts, "/alerts/active", queryOptions{allowNull: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -258,7 +258,7 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
var alerts weatherdata.AlertRun
|
var alerts weatherdata.AlertRun
|
||||||
if err := decodeSource(raw, &alerts); err != nil {
|
if err := decodeSource(raw, &alerts); err != nil {
|
||||||
return b.handleMalformed(&source, err, sourceRequest{name: "alerts"})
|
return b.handleMalformed(&source, err, sourceRequest{name: config.MissingSourceAlerts})
|
||||||
}
|
}
|
||||||
alerts.Raw = append(json.RawMessage(nil), raw...)
|
alerts.Raw = append(json.RawMessage(nil), raw...)
|
||||||
if alerts.AsOf != nil {
|
if alerts.AsOf != nil {
|
||||||
@@ -272,7 +272,7 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
|||||||
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||||
var discussion weatherdata.Discussion
|
var discussion weatherdata.Discussion
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
name: "discussion",
|
name: config.MissingSourceDiscussion,
|
||||||
endpoint: "/discussion",
|
endpoint: "/discussion",
|
||||||
query: queryOptions{timezone: true},
|
query: queryOptions{timezone: true},
|
||||||
missingMessage: "forecast discussion data is missing",
|
missingMessage: "forecast discussion data is missing",
|
||||||
@@ -291,7 +291,7 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
|||||||
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||||
var story weatherdata.WeatherStory
|
var story weatherdata.WeatherStory
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
name: "weather_story",
|
name: config.MissingSourceWeatherStory,
|
||||||
endpoint: "/weatherstories/latest",
|
endpoint: "/weatherstories/latest",
|
||||||
query: queryOptions{omitUnits: true},
|
query: queryOptions{omitUnits: true},
|
||||||
missingMessage: "NWS weather story data is missing",
|
missingMessage: "NWS weather story data is missing",
|
||||||
|
|||||||
@@ -21,6 +21,33 @@ const (
|
|||||||
NotifyFailureError NotifyFailurePolicy = "error"
|
NotifyFailureError NotifyFailurePolicy = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MissingSourceObservations = "observations"
|
||||||
|
MissingSourceCurrent = "current"
|
||||||
|
MissingSourceNarrative = "narrative"
|
||||||
|
MissingSourceAlerts = "alerts"
|
||||||
|
MissingSourceDiscussion = "discussion"
|
||||||
|
MissingSourceWeatherStory = "weather_story"
|
||||||
|
MissingSourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
||||||
|
)
|
||||||
|
|
||||||
|
var supportedMissingSources = map[string]struct{}{
|
||||||
|
MissingSourceObservations: {},
|
||||||
|
MissingSourceCurrent: {},
|
||||||
|
MissingSourceNarrative: {},
|
||||||
|
MissingSourceAlerts: {},
|
||||||
|
MissingSourceDiscussion: {},
|
||||||
|
MissingSourceWeatherStory: {},
|
||||||
|
MissingSourceSPCConvectiveOutlooks: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSupportedMissingSource reports whether source accepts a missing-source
|
||||||
|
// policy override. Required sources, including hourly, are not configurable.
|
||||||
|
func IsSupportedMissingSource(source string) bool {
|
||||||
|
_, ok := supportedMissingSources[source]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
||||||
Location LocationConfig `yaml:"location"`
|
Location LocationConfig `yaml:"location"`
|
||||||
@@ -246,7 +273,11 @@ func (c *ReportDistributorConfig) UnmarshalYAML(value *yaml.Node) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c ReportDistributorConfig) PathTemplatesSet() bool {
|
func (c ReportDistributorConfig) PathTemplatesSet() bool {
|
||||||
return c.pathTemplatesSet
|
return c.pathTemplatesSet || c.PathTemplates != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ReportConfig) deterministicModulesConfigured() bool {
|
||||||
|
return c.deterministicModulesSet || c.DeterministicModules != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error {
|
func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
|||||||
@@ -1066,6 +1066,44 @@ func TestInvalidConfigProducesActionableError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMissingSourcePoliciesOnlyAllowSupportedOptionalSources(t *testing.T) {
|
||||||
|
for _, source := range []string{
|
||||||
|
MissingSourceObservations,
|
||||||
|
MissingSourceCurrent,
|
||||||
|
MissingSourceNarrative,
|
||||||
|
MissingSourceAlerts,
|
||||||
|
MissingSourceDiscussion,
|
||||||
|
MissingSourceWeatherStory,
|
||||||
|
MissingSourceSPCConvectiveOutlooks,
|
||||||
|
} {
|
||||||
|
t.Run("supported_"+source, func(t *testing.T) {
|
||||||
|
cfg := Defaults()
|
||||||
|
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{source: MissingSourceNone}
|
||||||
|
if err := Validate(cfg); err != nil {
|
||||||
|
t.Fatalf("Validate() error = %v", err)
|
||||||
|
}
|
||||||
|
loaded, err := LoadFile(writeConfig(t, "missing_source:\n sources:\n "+source+": none\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if loaded.MissingSource.Sources[source] != MissingSourceNone {
|
||||||
|
t.Fatalf("loaded source policy = %q, want none", loaded.MissingSource.Sources[source])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, source := range []string{"alert", "unknown", "hourly", ""} {
|
||||||
|
t.Run("unsupported_"+source, func(t *testing.T) {
|
||||||
|
cfg := Defaults()
|
||||||
|
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{source: MissingSourceWarn}
|
||||||
|
err := Validate(cfg)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "missing_source.sources") {
|
||||||
|
t.Fatalf("Validate() error = %v, want unsupported source error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadAppliesOverrides(t *testing.T) {
|
func TestLoadAppliesOverrides(t *testing.T) {
|
||||||
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
|
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
75
internal/config/overrides_external_test.go
Normal file
75
internal/config/overrides_external_test.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConstructedReportOverridesUseExportedFields(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.Reports = map[string]config.ReportConfig{
|
||||||
|
"daily": {
|
||||||
|
DeterministicModules: []config.ModuleConfigItem{
|
||||||
|
{ID: module.Metadata},
|
||||||
|
{ID: module.AreaForecastDiscussion},
|
||||||
|
},
|
||||||
|
Distributor: config.ReportDistributorConfig{
|
||||||
|
PathTemplates: []string{"daily/{valid_start_date}/index.md"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Validate(cfg); err != nil {
|
||||||
|
t.Fatalf("Validate() error = %v", err)
|
||||||
|
}
|
||||||
|
modules, err := cfg.ReportModuleOverrides()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := modules[report.Daily]; len(got) != 2 || got[0].ID != module.Metadata || got[1].ID != module.AreaForecastDiscussion {
|
||||||
|
t.Fatalf("module override = %#v, want exported daily modules", got)
|
||||||
|
}
|
||||||
|
paths, err := cfg.ReportDistributorPathOverrides()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReportDistributorPathOverrides() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := paths[report.Daily]; len(got) != 1 || got[0] != "daily/{valid_start_date}/index.md" {
|
||||||
|
t.Fatalf("path override = %#v, want exported daily path", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConstructedExplicitEmptyReportOverridesAreRejected(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
report config.ReportConfig
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "modules",
|
||||||
|
report: config.ReportConfig{DeterministicModules: []config.ModuleConfigItem{}},
|
||||||
|
wantErr: "reports.daily.deterministic_modules must contain at least one entry",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "paths",
|
||||||
|
report: config.ReportConfig{Distributor: config.ReportDistributorConfig{
|
||||||
|
PathTemplates: []string{},
|
||||||
|
}},
|
||||||
|
wantErr: "reports.daily.distributor.path_templates must contain at least one entry",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.Reports = map[string]config.ReportConfig{"daily": tt.report}
|
||||||
|
err := config.Validate(cfg)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,9 +72,12 @@ func traverseReportModules(cfg *Config, opts reportModuleTraversalOptions) (map[
|
|||||||
if _, err := reportRegistry.Lookup(reportID); err != nil {
|
if _, err := reportRegistry.Lookup(reportID); err != nil {
|
||||||
return nil, fmt.Errorf("reports.%s: %w", key, err)
|
return nil, fmt.Errorf("reports.%s: %w", key, err)
|
||||||
}
|
}
|
||||||
if !reportCfg.deterministicModulesSet {
|
if !reportCfg.deterministicModulesConfigured() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if len(reportCfg.DeterministicModules) == 0 {
|
||||||
|
return nil, fmt.Errorf("reports.%s.deterministic_modules must contain at least one entry", key)
|
||||||
|
}
|
||||||
items, normalized, err := moduleItemsFromConfig(moduleRegistry, key, reportCfg.DeterministicModules, opts.normalizeOptions)
|
items, normalized, err := moduleItemsFromConfig(moduleRegistry, key, reportCfg.DeterministicModules, opts.normalizeOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -110,7 +113,7 @@ func traverseReportDistributorPathOverrides(cfg Config) (map[report.ID][]string,
|
|||||||
if _, err := reportRegistry.Lookup(reportID); err != nil {
|
if _, err := reportRegistry.Lookup(reportID); err != nil {
|
||||||
return nil, fmt.Errorf("reports.%s: %w", key, err)
|
return nil, fmt.Errorf("reports.%s: %w", key, err)
|
||||||
}
|
}
|
||||||
if !reportCfg.Distributor.pathTemplatesSet {
|
if !reportCfg.Distributor.PathTemplatesSet() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := validateReportDistributorPathTemplates(key, reportID, reportCfg.Distributor.PathTemplates); err != nil {
|
if err := validateReportDistributorPathTemplates(key, reportID, reportCfg.Distributor.PathTemplates); err != nil {
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ func Validate(cfg Config) error {
|
|||||||
if strings.TrimSpace(source) == "" {
|
if strings.TrimSpace(source) == "" {
|
||||||
return fmt.Errorf("missing_source.sources contains an empty source name")
|
return fmt.Errorf("missing_source.sources contains an empty source name")
|
||||||
}
|
}
|
||||||
|
if !IsSupportedMissingSource(source) {
|
||||||
|
return fmt.Errorf("missing_source.sources.%s is not a supported optional source", source)
|
||||||
|
}
|
||||||
if err := validatePolicy("missing_source.sources."+source, policy); err != nil {
|
if err := validatePolicy("missing_source.sources."+source, policy); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user