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`. |
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user