Validate configuration source keys and overrides

This commit is contained in:
2026-08-13 00:15:26 +00:00
parent 5139c1a586
commit 26a681e0b1
7 changed files with 173 additions and 17 deletions

View File

@@ -53,7 +53,11 @@ All omitted fields use their built-in defaults.
| `format` | `json` | Required and must be `json`. |
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`
@@ -161,9 +165,10 @@ output selection, and failure handling.
`missing_source.default` defaults to `warn` and accepts `error`, `warn`, or
`none`. `missing_source.sources` optionally overrides that policy by source.
Hourly forecast data is required for generated reports. Supported optional
source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
`weather_story`, and `spc_convective_outlooks`.
Hourly forecast data is required for generated reports and cannot have a
source-specific policy. Supported optional source keys are `observations`,
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
`spc_convective_outlooks`; any other key is rejected.
### `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
Distributor path templates. Omit a report entry to retain its defaults.
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`; hyphens
and underscores are equivalent.
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`. Keys are
trimmed, case-folded to lowercase, and normalize hyphens to underscores before
lookup.
Each report entry can contain:

View File

@@ -23,7 +23,7 @@ import (
const (
convectiveOutlooksEndpoint = "/outlooks/convective"
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
sourceSPCConvectiveOutlooks = config.MissingSourceSPCConvectiveOutlooks
defaultWarmupEndpoint = "/conditions/current"
defaultWarmupAttempts = 3
@@ -168,7 +168,7 @@ type fetchedSource struct {
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
var observation weatherdata.Observation
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "observations",
name: config.MissingSourceObservations,
endpoint: "/observations",
query: queryOptions{precision: true},
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 {
var current weatherdata.Current
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "current",
name: config.MissingSourceCurrent,
endpoint: "/conditions/current",
query: queryOptions{precision: true},
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 {
var narrative weatherdata.ForecastRun
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "narrative",
name: config.MissingSourceNarrative,
endpoint: "/forecast/narrative",
query: queryOptions{precision: true, timezone: true},
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 {
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 {
return err
}
@@ -258,7 +258,7 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
}
var alerts weatherdata.AlertRun
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...)
if alerts.AsOf != nil {
@@ -272,7 +272,7 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
var discussion weatherdata.Discussion
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "discussion",
name: config.MissingSourceDiscussion,
endpoint: "/discussion",
query: queryOptions{timezone: true},
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 {
var story weatherdata.WeatherStory
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "weather_story",
name: config.MissingSourceWeatherStory,
endpoint: "/weatherstories/latest",
query: queryOptions{omitUnits: true},
missingMessage: "NWS weather story data is missing",

View File

@@ -21,6 +21,33 @@ const (
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 {
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
Location LocationConfig `yaml:"location"`
@@ -246,7 +273,11 @@ func (c *ReportDistributorConfig) UnmarshalYAML(value *yaml.Node) error {
}
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 {

View File

@@ -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) {
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
if err != nil {

View 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)
}
})
}
}

View File

@@ -72,9 +72,12 @@ func traverseReportModules(cfg *Config, opts reportModuleTraversalOptions) (map[
if _, err := reportRegistry.Lookup(reportID); err != nil {
return nil, fmt.Errorf("reports.%s: %w", key, err)
}
if !reportCfg.deterministicModulesSet {
if !reportCfg.deterministicModulesConfigured() {
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)
if err != nil {
return nil, err
@@ -110,7 +113,7 @@ func traverseReportDistributorPathOverrides(cfg Config) (map[report.ID][]string,
if _, err := reportRegistry.Lookup(reportID); err != nil {
return nil, fmt.Errorf("reports.%s: %w", key, err)
}
if !reportCfg.Distributor.pathTemplatesSet {
if !reportCfg.Distributor.PathTemplatesSet() {
continue
}
if err := validateReportDistributorPathTemplates(key, reportID, reportCfg.Distributor.PathTemplates); err != nil {

View File

@@ -53,6 +53,9 @@ func Validate(cfg Config) error {
if strings.TrimSpace(source) == "" {
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 {
return err
}