Compare commits
17 Commits
fba519cab0
...
v0.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d7f604a2c | |||
| 8041f99782 | |||
| c417c892d9 | |||
| 481215c5db | |||
| 5d94d3f32d | |||
| f8f1b8d4a5 | |||
| 06d5973746 | |||
| 8045b27173 | |||
| 985468c1b9 | |||
| 6a0b30b7c7 | |||
| 86ce4eb68c | |||
| 33541a71fc | |||
| b7277e0c02 | |||
| dec05821bf | |||
| 1a9f462fbf | |||
| a990da957b | |||
| a4cd63ca4e |
@@ -1,7 +1,8 @@
|
|||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
**/*.md
|
**/*.md
|
||||||
|
!docs/*.md
|
||||||
|
!docs/**/*.md
|
||||||
dist/
|
dist/
|
||||||
tmp/
|
tmp/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ current working directory.
|
|||||||
- [Operations guide](docs/operations.md)
|
- [Operations guide](docs/operations.md)
|
||||||
- [Troubleshooting guide](docs/troubleshooting.md)
|
- [Troubleshooting guide](docs/troubleshooting.md)
|
||||||
- [Example configs](examples/)
|
- [Example configs](examples/)
|
||||||
|
- [Go consumer guide](docs/consumers/api.md)
|
||||||
- [Event wire contract](docs/integrations/events.md)
|
- [Event wire contract](docs/integrations/events.md)
|
||||||
- [Postgres table contract](docs/integrations/postgres.md)
|
- [Postgres table contract](docs/integrations/postgres.md)
|
||||||
|
- [Feedkit integration notes](docs/integrations/feedkit.md)
|
||||||
- [NWS integration notes](docs/integrations/nws.md)
|
- [NWS integration notes](docs/integrations/nws.md)
|
||||||
- [SPC integration notes](docs/integrations/spc.md)
|
- [SPC integration notes](docs/integrations/spc.md)
|
||||||
- [Open-Meteo integration notes](docs/integrations/openmeteo.md)
|
- [Open-Meteo integration notes](docs/integrations/openmeteo.md)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
wfnormalizers "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers"
|
wfnormalizers "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers"
|
||||||
wfsources "gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources"
|
wfsources "gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||||
)
|
)
|
||||||
|
|
||||||
type testInput struct {
|
type testInput struct {
|
||||||
@@ -36,10 +37,10 @@ type testKindsSource struct {
|
|||||||
func (s testKindsSource) Kinds() []fkevent.Kind { return s.kinds }
|
func (s testKindsSource) Kinds() []fkevent.Kind { return s.kinds }
|
||||||
|
|
||||||
func TestValidateSourceExpectedKindsSubsetAllowed(t *testing.T) {
|
func TestValidateSourceExpectedKindsSubsetAllowed(t *testing.T) {
|
||||||
sc := config.SourceConfig{Kinds: []string{"observation"}}
|
sc := config.SourceConfig{Kinds: []string{standards.KindObservation}}
|
||||||
in := testKindsSource{
|
in := testKindsSource{
|
||||||
testInput: testInput{name: "test"},
|
testInput: testInput{name: "test"},
|
||||||
kinds: []fkevent.Kind{"observation", "forecast"},
|
kinds: []fkevent.Kind{fkevent.Kind(standards.KindObservation), fkevent.Kind(standards.KindForecast)},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := fksources.ValidateExpectedKinds(sc, in); err != nil {
|
if err := fksources.ValidateExpectedKinds(sc, in); err != nil {
|
||||||
@@ -48,10 +49,10 @@ func TestValidateSourceExpectedKindsSubsetAllowed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateSourceExpectedKindsMismatchFails(t *testing.T) {
|
func TestValidateSourceExpectedKindsMismatchFails(t *testing.T) {
|
||||||
sc := config.SourceConfig{Kinds: []string{"alert"}}
|
sc := config.SourceConfig{Kinds: []string{standards.KindAlert}}
|
||||||
in := testKindsSource{
|
in := testKindsSource{
|
||||||
testInput: testInput{name: "test"},
|
testInput: testInput{name: "test"},
|
||||||
kinds: []fkevent.Kind{"observation", "forecast"},
|
kinds: []fkevent.Kind{fkevent.Kind(standards.KindObservation), fkevent.Kind(standards.KindForecast)},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := fksources.ValidateExpectedKinds(sc, in)
|
err := fksources.ValidateExpectedKinds(sc, in)
|
||||||
@@ -64,7 +65,7 @@ func TestValidateSourceExpectedKindsMismatchFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateSourceExpectedKindsNoMetadataSkipsCheck(t *testing.T) {
|
func TestValidateSourceExpectedKindsNoMetadataSkipsCheck(t *testing.T) {
|
||||||
sc := config.SourceConfig{Kinds: []string{"alert"}}
|
sc := config.SourceConfig{Kinds: []string{standards.KindAlert}}
|
||||||
in := testInput{name: "test"}
|
in := testInput{name: "test"}
|
||||||
|
|
||||||
if err := fksources.ValidateExpectedKinds(sc, in); err != nil {
|
if err := fksources.ValidateExpectedKinds(sc, in); err != nil {
|
||||||
@@ -111,6 +112,10 @@ func TestMaintainedConfigExamplesLoad(t *testing.T) {
|
|||||||
func assertConfigSourcesBuildSchedulerJobs(t *testing.T, cfg *config.Config) {
|
func assertConfigSourcesBuildSchedulerJobs(t *testing.T, cfg *config.Config) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
if len(cfg.Sources) == 0 {
|
||||||
|
t.Fatalf("config has no sources")
|
||||||
|
}
|
||||||
|
|
||||||
reg := fksources.NewRegistry()
|
reg := fksources.NewRegistry()
|
||||||
wfsources.RegisterBuiltins(reg)
|
wfsources.RegisterBuiltins(reg)
|
||||||
|
|
||||||
@@ -158,7 +163,7 @@ func TestNormalizeNoMatchPassThrough(t *testing.T) {
|
|||||||
pl := &fkpipeline.Pipeline{Processors: chain}
|
pl := &fkpipeline.Pipeline{Processors: chain}
|
||||||
in := fkevent.Event{
|
in := fkevent.Event{
|
||||||
ID: "evt-no-match",
|
ID: "evt-no-match",
|
||||||
Kind: fkevent.Kind("observation"),
|
Kind: fkevent.Kind(standards.KindObservation),
|
||||||
Source: "test",
|
Source: "test",
|
||||||
EmittedAt: time.Now().UTC(),
|
EmittedAt: time.Now().UTC(),
|
||||||
Schema: "raw.weatherfeeder.unknown.v1",
|
Schema: "raw.weatherfeeder.unknown.v1",
|
||||||
@@ -188,7 +193,7 @@ func TestDedupeDropsSecondEventWithSameID(t *testing.T) {
|
|||||||
pl := &fkpipeline.Pipeline{Processors: chain}
|
pl := &fkpipeline.Pipeline{Processors: chain}
|
||||||
in := fkevent.Event{
|
in := fkevent.Event{
|
||||||
ID: "evt-dedupe-1",
|
ID: "evt-dedupe-1",
|
||||||
Kind: fkevent.Kind("observation"),
|
Kind: fkevent.Kind(standards.KindObservation),
|
||||||
Source: "test",
|
Source: "test",
|
||||||
EmittedAt: time.Now().UTC(),
|
EmittedAt: time.Now().UTC(),
|
||||||
Schema: "raw.weatherfeeder.unknown.v1",
|
Schema: "raw.weatherfeeder.unknown.v1",
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ URL omits it or sets another unit system.
|
|||||||
|
|
||||||
## SPC Convective Outlook Params
|
## SPC Convective Outlook Params
|
||||||
|
|
||||||
`spc_convective_outlook` fetches the twelve required Day 1-3 GeoJSON outlook
|
`spc_convective_outlook` fetches the nine required Day 1-3 GeoJSON outlook
|
||||||
products and the three required Day 1-3 print pages as one atomic bundle.
|
products and the three required Day 1-3 print pages as one atomic bundle.
|
||||||
|
|
||||||
| Param | Required | Description |
|
| Param | Required | Description |
|
||||||
@@ -165,8 +165,8 @@ products and the three required Day 1-3 print pages as one atomic bundle.
|
|||||||
|
|
||||||
GeoJSON product keys are `day1_categorical`, `day1_tornado`, `day1_hail`,
|
GeoJSON product keys are `day1_categorical`, `day1_tornado`, `day1_hail`,
|
||||||
`day1_wind`, `day2_categorical`, `day2_tornado`, `day2_hail`, `day2_wind`,
|
`day1_wind`, `day2_categorical`, `day2_tornado`, `day2_hail`, `day2_wind`,
|
||||||
`day3_categorical`, `day3_tornado`, `day3_hail`, and `day3_wind`.
|
and `day3_categorical`. SPC does not provide Day 3 tornado, hail, or wind
|
||||||
Discussion keys are `day1`, `day2`, and `day3`.
|
GeoJSON products. Discussion keys are `day1`, `day2`, and `day3`.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
sources:
|
sources:
|
||||||
|
|||||||
92
docs/consumers/api.md
Normal file
92
docs/consumers/api.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Consumer API Guide
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This guide is for developers and LLM coding agents integrating `weatherfeeder`
|
||||||
|
from another Go codebase.
|
||||||
|
|
||||||
|
`weatherfeeder` is primarily a daemon, not an SDK. Its public integration
|
||||||
|
surface is intentionally narrow:
|
||||||
|
|
||||||
|
- `model`: canonical weather payload structs.
|
||||||
|
- `standards`: schema strings, event kind strings, and shared WMO constants.
|
||||||
|
- JSON event output from stdout and NATS sinks.
|
||||||
|
- Postgres tables written by the optional Postgres sink.
|
||||||
|
|
||||||
|
Packages under `internal/` are implementation details and are not public
|
||||||
|
integration surfaces.
|
||||||
|
|
||||||
|
## Recommended Workflow
|
||||||
|
|
||||||
|
Consumers should switch on the event `schema` value and decode `payload` into
|
||||||
|
the matching `model` type.
|
||||||
|
|
||||||
|
Minimal example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package consumer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Schema string `json:"schema"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Decode(payload []byte) (any, error) {
|
||||||
|
var evt Event
|
||||||
|
if err := json.Unmarshal(payload, &evt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch evt.Schema {
|
||||||
|
case standards.SchemaWeatherObservationV1:
|
||||||
|
var out model.WeatherObservation
|
||||||
|
return &out, json.Unmarshal(evt.Payload, &out)
|
||||||
|
case standards.SchemaWeatherForecastV1:
|
||||||
|
var out model.WeatherForecastRun
|
||||||
|
return &out, json.Unmarshal(evt.Payload, &out)
|
||||||
|
case standards.SchemaWeatherForecastDiscussionV1:
|
||||||
|
var out model.WeatherForecastDiscussion
|
||||||
|
return &out, json.Unmarshal(evt.Payload, &out)
|
||||||
|
case standards.SchemaWeatherStoryV1:
|
||||||
|
var out model.WeatherStoryRun
|
||||||
|
return &out, json.Unmarshal(evt.Payload, &out)
|
||||||
|
case standards.SchemaWeatherAlertV1:
|
||||||
|
var out model.WeatherAlertRun
|
||||||
|
return &out, json.Unmarshal(evt.Payload, &out)
|
||||||
|
case standards.SchemaWeatherOutlookV1:
|
||||||
|
var out model.WeatherOutlookRun
|
||||||
|
return &out, json.Unmarshal(evt.Payload, &out)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported weatherfeeder schema %q", evt.Schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consumer Responsibilities
|
||||||
|
|
||||||
|
- Treat event IDs as opaque.
|
||||||
|
- Treat absent `omitempty` fields as unknown, not zero.
|
||||||
|
- Prefer schema constants from `standards` over string literals in Go code.
|
||||||
|
- Expect canonical numeric measurements to use metric units.
|
||||||
|
- Expect canonical timestamps from normalizers to be UTC unless a field-specific
|
||||||
|
contract says otherwise.
|
||||||
|
- Handle additive fields within the same schema version.
|
||||||
|
- Do not import `internal/...` packages.
|
||||||
|
|
||||||
|
## Canonical References
|
||||||
|
|
||||||
|
- Public payload package: [`pkg-model.md`](pkg-model.md).
|
||||||
|
- Public constants package: [`pkg-standards.md`](pkg-standards.md).
|
||||||
|
- JSON event wire contract: [`../integrations/events.md`](../integrations/events.md).
|
||||||
|
- Postgres table contract: [`../integrations/postgres.md`](../integrations/postgres.md).
|
||||||
|
- Runtime and adapter architecture: [`../policy/architecture.md`](../policy/architecture.md).
|
||||||
63
docs/consumers/pkg-model.md
Normal file
63
docs/consumers/pkg-model.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Package `model`
|
||||||
|
|
||||||
|
## Import Path
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Package `model` defines `weatherfeeder`'s canonical weather payload structs.
|
||||||
|
These structs are emitted as the `payload` of canonical `weather.*.v1` events
|
||||||
|
and are also the domain types consumed by downstream applications such as
|
||||||
|
`weatherapi`.
|
||||||
|
|
||||||
|
The JSON field tags on these structs are part of the wire contract. For the full
|
||||||
|
field-by-field JSON contract, use the [event wire contract](../integrations/events.md).
|
||||||
|
|
||||||
|
## Payload Types
|
||||||
|
|
||||||
|
Current canonical schema families map to these public types:
|
||||||
|
|
||||||
|
| Schema | Primary type |
|
||||||
|
|---|---|
|
||||||
|
| `weather.observation.v1` | `WeatherObservation` |
|
||||||
|
| `weather.forecast.v1` | `WeatherForecastRun` |
|
||||||
|
| `weather.forecast_discussion.v1` | `WeatherForecastDiscussion` |
|
||||||
|
| `weather.weather_story.v1` | `WeatherStoryRun` |
|
||||||
|
| `weather.alert.v1` | `WeatherAlertRun` |
|
||||||
|
| `weather.outlook.v1` | `WeatherOutlookRun` |
|
||||||
|
|
||||||
|
Related child types include:
|
||||||
|
|
||||||
|
- `WeatherObservationPresentWeather`
|
||||||
|
- `WeatherForecastPeriod`
|
||||||
|
- `WeatherForecastDiscussionSection`
|
||||||
|
- `WeatherStory`
|
||||||
|
- `WeatherAlert`
|
||||||
|
- `WeatherAlertReference`
|
||||||
|
- `WeatherOutlook`
|
||||||
|
- `WMOCode`
|
||||||
|
|
||||||
|
## Wire And Compatibility Rules
|
||||||
|
|
||||||
|
- JSON tags define canonical payload field names.
|
||||||
|
- Pointer fields and fields tagged `omitempty` are optional on the wire.
|
||||||
|
- Missing optional fields mean unknown or not applicable.
|
||||||
|
- Canonical measurements use metric units.
|
||||||
|
- Canonical timestamps are `time.Time` values encoded by Go's JSON encoder.
|
||||||
|
- Normalized canonical timestamps are UTC unless a field-specific contract says
|
||||||
|
otherwise.
|
||||||
|
- Additive fields are compatible within a schema version.
|
||||||
|
- Removing, renaming, or changing the meaning of a field requires a new schema
|
||||||
|
identifier.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
`model` should not depend on source adapters, sinks, SQL column names, provider
|
||||||
|
HTTP shapes, or runtime configuration.
|
||||||
|
|
||||||
|
Consumers should not rely on packages under `internal/...`. Use `model` with
|
||||||
|
schema constants from [`standards`](pkg-standards.md) and the JSON contract in
|
||||||
|
[`docs/integrations/events.md`](../integrations/events.md).
|
||||||
90
docs/consumers/pkg-standards.md
Normal file
90
docs/consumers/pkg-standards.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Package `standards`
|
||||||
|
|
||||||
|
## Import Path
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Package `standards` defines stable identifiers and shared weather constants used
|
||||||
|
by `weatherfeeder` producers and Go consumers.
|
||||||
|
|
||||||
|
Use this package when switching on event schemas, comparing event kinds, or
|
||||||
|
working with canonical WMO condition codes.
|
||||||
|
|
||||||
|
## Event Kind Constants
|
||||||
|
|
||||||
|
Current event kind constants are:
|
||||||
|
|
||||||
|
| Constant | Value |
|
||||||
|
|---|---|
|
||||||
|
| `KindObservation` | `observation` |
|
||||||
|
| `KindForecast` | `forecast` |
|
||||||
|
| `KindForecastDiscussion` | `forecast_discussion` |
|
||||||
|
| `KindWeatherStory` | `weather_story` |
|
||||||
|
| `KindAlert` | `alert` |
|
||||||
|
| `KindOutlook` | `outlook` |
|
||||||
|
|
||||||
|
These are plain string constants. Convert them at adapter boundaries when using
|
||||||
|
feedkit's `event.Kind` type.
|
||||||
|
|
||||||
|
## Canonical Schema Constants
|
||||||
|
|
||||||
|
Canonical schemas emitted after normalization:
|
||||||
|
|
||||||
|
| Constant | Value |
|
||||||
|
|---|---|
|
||||||
|
| `SchemaWeatherObservationV1` | `weather.observation.v1` |
|
||||||
|
| `SchemaWeatherForecastV1` | `weather.forecast.v1` |
|
||||||
|
| `SchemaWeatherForecastDiscussionV1` | `weather.forecast_discussion.v1` |
|
||||||
|
| `SchemaWeatherStoryV1` | `weather.weather_story.v1` |
|
||||||
|
| `SchemaWeatherAlertV1` | `weather.alert.v1` |
|
||||||
|
| `SchemaWeatherOutlookV1` | `weather.outlook.v1` |
|
||||||
|
|
||||||
|
## Raw Schema Constants
|
||||||
|
|
||||||
|
Raw source schemas emitted by current registered sources:
|
||||||
|
|
||||||
|
| Constant | Value |
|
||||||
|
|---|---|
|
||||||
|
| `SchemaRawNWSObservationV1` | `raw.nws.observation.v1` |
|
||||||
|
| `SchemaRawOpenMeteoCurrentV1` | `raw.openmeteo.current.v1` |
|
||||||
|
| `SchemaRawOpenWeatherCurrentV1` | `raw.openweather.current.v1` |
|
||||||
|
| `SchemaRawNWSHourlyForecastV1` | `raw.nws.hourly.forecast.v1` |
|
||||||
|
| `SchemaRawNWSNarrativeForecastV1` | `raw.nws.narrative.forecast.v1` |
|
||||||
|
| `SchemaRawNWSForecastDiscussionV1` | `raw.nws.forecast_discussion.v1` |
|
||||||
|
| `SchemaRawNWSWeatherStoriesV1` | `raw.nws.weatherstories.v1` |
|
||||||
|
| `SchemaRawOpenMeteoHourlyForecastV1` | `raw.openmeteo.hourly.forecast.v1` |
|
||||||
|
| `SchemaRawNWSAlertsV1` | `raw.nws.alerts.v1` |
|
||||||
|
| `SchemaRawSPCConvectiveOutlookV1` | `raw.spc.convective_outlook.v1` |
|
||||||
|
|
||||||
|
Additional raw schema constant:
|
||||||
|
|
||||||
|
| Constant | Value |
|
||||||
|
|---|---|
|
||||||
|
| `SchemaRawOpenWeatherHourlyForecastV1` | `raw.openweather.hourly.forecast.v1` |
|
||||||
|
|
||||||
|
`SchemaRawOpenWeatherHourlyForecastV1` exists in code, but no current registered
|
||||||
|
source emits it. Consumers should not expect that raw schema unless a later
|
||||||
|
registered source documents it as part of the current event contract.
|
||||||
|
|
||||||
|
## WMO Constants And Text
|
||||||
|
|
||||||
|
`standards` also defines the canonical `WMOCode` vocabulary and text helpers
|
||||||
|
used by normalized observations and forecasts.
|
||||||
|
|
||||||
|
Consumer guidance:
|
||||||
|
|
||||||
|
- Treat `WMOUnknown` as unknown condition data.
|
||||||
|
- Observation `conditionCode` is required in the current event contract.
|
||||||
|
- Forecast period `conditionCode` is optional because some forecast products do
|
||||||
|
not provide a meaningful WMO condition.
|
||||||
|
- Prefer WMO constants and helper functions from this package instead of
|
||||||
|
duplicating code tables in consumers.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
`standards` is provider-agnostic. Provider-specific parsing belongs in
|
||||||
|
`weatherfeeder` internals, not in this package and not in consumers.
|
||||||
106
docs/integrations/feedkit.md
Normal file
106
docs/integrations/feedkit.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Feedkit Integration
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document describes the feedkit runtime behavior that `weatherfeeder`
|
||||||
|
currently depends on. It is for maintainers and LLM coding agents changing
|
||||||
|
runtime wiring, config behavior, source construction, processing, routing, or
|
||||||
|
sink behavior.
|
||||||
|
|
||||||
|
Weather-domain behavior belongs in `weatherfeeder`. Generic daemon mechanics
|
||||||
|
belong to feedkit.
|
||||||
|
|
||||||
|
## Current Dependency
|
||||||
|
|
||||||
|
`weatherfeeder` imports feedkit as its daemon framework dependency. The exact
|
||||||
|
module version is declared in `go.mod`.
|
||||||
|
|
||||||
|
Feedkit provides:
|
||||||
|
|
||||||
|
- YAML config loading and validation.
|
||||||
|
- Source, processor, and sink registries.
|
||||||
|
- HTTP source helper behavior.
|
||||||
|
- Scheduler polling.
|
||||||
|
- Normalize and dedupe processors.
|
||||||
|
- Route compilation and sink dispatch.
|
||||||
|
- Built-in stdout, NATS, and Postgres sink mechanics.
|
||||||
|
|
||||||
|
## Config Contract
|
||||||
|
|
||||||
|
`cmd/weatherfeeder` calls feedkit config loading for `config.yml` in the current
|
||||||
|
working directory.
|
||||||
|
|
||||||
|
Implemented behavior relied on by weatherfeeder docs and tests:
|
||||||
|
|
||||||
|
- Top-level config contains `sources`, `sinks`, and optional `routes`.
|
||||||
|
- Config struct fields are decoded strictly, so misspelled struct fields fail
|
||||||
|
startup.
|
||||||
|
- Driver-specific `params` maps are decoded generically and validated by the
|
||||||
|
source or sink constructor that consumes them.
|
||||||
|
- Source `kinds` can be validated against a source's advertised `Kinds()`.
|
||||||
|
|
||||||
|
## Source And HTTP Contract
|
||||||
|
|
||||||
|
Most weatherfeeder sources use feedkit's single-document HTTP source helper for:
|
||||||
|
|
||||||
|
- request construction;
|
||||||
|
- `User-Agent` and `Accept` headers;
|
||||||
|
- optional conditional GET validators;
|
||||||
|
- response body size limits;
|
||||||
|
- context-aware HTTP work;
|
||||||
|
- unchanged `304 Not Modified` responses that emit no events.
|
||||||
|
|
||||||
|
The SPC convective outlook source fetches multiple documents itself, but it uses
|
||||||
|
feedkit transport helpers for HTTP clients and response body limits.
|
||||||
|
|
||||||
|
## Scheduler And Processing Contract
|
||||||
|
|
||||||
|
Weatherfeeder builds feedkit scheduler jobs from source configs. Current source
|
||||||
|
drivers are polling drivers and use the configured `every` interval.
|
||||||
|
|
||||||
|
Events flow through a feedkit pipeline in this order:
|
||||||
|
|
||||||
|
1. normalize processor;
|
||||||
|
2. dedupe processor.
|
||||||
|
|
||||||
|
The normalize processor is configured with `RequireMatch=false`, so unmatched
|
||||||
|
schemas pass through unchanged. Weatherfeeder registers its built-in normalizers
|
||||||
|
and owns the provider-to-canonical mapping.
|
||||||
|
|
||||||
|
The dedupe processor stores a bounded in-memory set of recent event IDs. The
|
||||||
|
bound is configured in `cmd/weatherfeeder`.
|
||||||
|
|
||||||
|
## Dispatch And Sink Contract
|
||||||
|
|
||||||
|
Feedkit compiles routes from config and dispatches processed events to matching
|
||||||
|
sinks. If `routes` is omitted, every configured sink receives every event kind.
|
||||||
|
|
||||||
|
Feedkit owns sink fanout mechanics, per-sink workers, queueing, context-aware
|
||||||
|
shutdown, and sink error logging. Weatherfeeder owns the event kinds and schemas
|
||||||
|
that make routes meaningful.
|
||||||
|
|
||||||
|
Built-in feedkit sinks used by weatherfeeder:
|
||||||
|
|
||||||
|
- `stdout`: validates and writes JSON events to stdout.
|
||||||
|
- `nats`: publishes JSON events to a configured subject.
|
||||||
|
- generic `postgres` sink factory: opens the database, ensures tables/indexes,
|
||||||
|
runs transactions, inserts mapped rows, and prunes when configured.
|
||||||
|
|
||||||
|
Weatherfeeder supplies its Postgres table schema and event mapper to feedkit's
|
||||||
|
Postgres sink factory. The table contract is documented in
|
||||||
|
[`postgres.md`](postgres.md).
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Do not move weather-domain policy into feedkit. Weatherfeeder owns:
|
||||||
|
|
||||||
|
- provider source drivers;
|
||||||
|
- raw and canonical schema constants;
|
||||||
|
- event kind meaning;
|
||||||
|
- canonical payload structs;
|
||||||
|
- normalizers;
|
||||||
|
- Postgres table shape and row mapping.
|
||||||
|
|
||||||
|
Do not duplicate generic feedkit mechanics in weatherfeeder unless there is a
|
||||||
|
narrow weather-specific reason. Runtime composition details are documented in
|
||||||
|
[`../internal/runtime.md`](../internal/runtime.md).
|
||||||
@@ -429,6 +429,8 @@ Indexes:
|
|||||||
| `run_event_id` | `TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE` | no | Parent event ID. |
|
| `run_event_id` | `TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE` | no | Parent event ID. |
|
||||||
| `outlook_index` | `INTEGER` | no | `payload.outlooks[]` index. |
|
| `outlook_index` | `INTEGER` | no | `payload.outlooks[]` index. |
|
||||||
| `as_of` | `TIMESTAMPTZ` | no | Parent `payload.asOf` |
|
| `as_of` | `TIMESTAMPTZ` | no | Parent `payload.asOf` |
|
||||||
|
| `outlook_id` | `TEXT` | no | `payload.outlooks[].id` |
|
||||||
|
| `provider` | `TEXT` | no | `payload.outlooks[].provider` |
|
||||||
| `product` | `TEXT` | no | `payload.outlooks[].product` |
|
| `product` | `TEXT` | no | `payload.outlooks[].product` |
|
||||||
| `day` | `INTEGER` | no | `payload.outlooks[].day` |
|
| `day` | `INTEGER` | no | `payload.outlooks[].day` |
|
||||||
| `outlook_type` | `TEXT` | no | `payload.outlooks[].outlookType` |
|
| `outlook_type` | `TEXT` | no | `payload.outlooks[].outlookType` |
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ current Day 1-3 SPC product URLs.
|
|||||||
|
|
||||||
## Upstream Products Used
|
## Upstream Products Used
|
||||||
|
|
||||||
The source fetches twelve required GeoJSON products every poll:
|
The source fetches nine required GeoJSON products every poll:
|
||||||
|
|
||||||
- Day 1 categorical, tornado, hail, and wind
|
- Day 1 categorical, tornado, hail, and wind
|
||||||
- Day 2 categorical, tornado, hail, and wind
|
- Day 2 categorical, tornado, hail, and wind
|
||||||
- Day 3 categorical, tornado, hail, and wind
|
- Day 3 categorical
|
||||||
|
|
||||||
It also fetches three required print pages:
|
It also fetches three required print pages:
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,21 @@ Runtime composition uses feedkit for:
|
|||||||
|
|
||||||
Weatherfeeder registers its own source drivers and its Postgres schema mapper.
|
Weatherfeeder registers its own source drivers and its Postgres schema mapper.
|
||||||
|
|
||||||
|
Responsibility split:
|
||||||
|
|
||||||
|
| Runtime concern | Owner |
|
||||||
|
| --- | --- |
|
||||||
|
| Config loading and generic validation | feedkit |
|
||||||
|
| Source, processor, and sink registries | feedkit mechanics; weatherfeeder registrations |
|
||||||
|
| Source polling and stream supervision | feedkit scheduler |
|
||||||
|
| Raw weather data fetching | weatherfeeder source adapters |
|
||||||
|
| Normalizer execution order and pass-through behavior | feedkit normalize processor |
|
||||||
|
| Weather raw-to-canonical mapping | weatherfeeder normalizers |
|
||||||
|
| Dedupe mechanics | feedkit dedupe processor |
|
||||||
|
| Route compilation and sink fanout | feedkit dispatch |
|
||||||
|
| Weather Postgres table shape and row mapping | weatherfeeder Postgres adapter |
|
||||||
|
| Postgres connection, DDL, inserts, transactions, and pruning | feedkit Postgres sink |
|
||||||
|
|
||||||
## State
|
## State
|
||||||
|
|
||||||
Weatherfeeder-owned runtime state is in process:
|
Weatherfeeder-owned runtime state is in process:
|
||||||
|
|||||||
@@ -23,6 +23,27 @@ The implemented runtime flow is:
|
|||||||
|
|
||||||
Canonical payload structs live in `model`. Schema identifiers and cross-provider wire conventions live in `standards`. Source adapters live under `internal/sources`. Normalizers live under `internal/normalizers`. Provider-specific parsing helpers shared by sources and normalizers live under `internal/providers`. Sink-specific persistence mapping lives under `internal/sinks`.
|
Canonical payload structs live in `model`. Schema identifiers and cross-provider wire conventions live in `standards`. Source adapters live under `internal/sources`. Normalizers live under `internal/normalizers`. Provider-specific parsing helpers shared by sources and normalizers live under `internal/providers`. Sink-specific persistence mapping lives under `internal/sinks`.
|
||||||
|
|
||||||
|
## Architecture Style
|
||||||
|
|
||||||
|
`weatherfeeder` uses a pragmatic ports-and-adapters architecture rather than a
|
||||||
|
formal framework. Provider APIs, config loading, scheduling, dispatch, and sinks
|
||||||
|
sit outside the weather domain model and normalization rules.
|
||||||
|
|
||||||
|
The implementation style is:
|
||||||
|
|
||||||
|
- Pipeline-oriented: events flow from source polling through normalization,
|
||||||
|
dedupe, routing, and sink fanout.
|
||||||
|
- Schema-routed: normalizers select raw payloads by explicit schema strings, not
|
||||||
|
source names or configured routes.
|
||||||
|
- Provider-isolated: NWS, Open-Meteo, OpenWeather, and SPC quirks stay in
|
||||||
|
provider-specific source, provider-helper, and normalizer packages.
|
||||||
|
- Registry-based: built-in source drivers, normalizers, processors, and sinks
|
||||||
|
are assembled explicitly through registries instead of dynamic plugin loading.
|
||||||
|
- Adapter-clean: persistence and external-system details stay behind source and
|
||||||
|
sink adapters, not in `model` or normalizers.
|
||||||
|
- Direct Go: prefer small package-level constructors and straightforward code
|
||||||
|
over broad abstractions.
|
||||||
|
|
||||||
## Core Design Principles
|
## Core Design Principles
|
||||||
|
|
||||||
- Hexagonal boundaries: provider APIs, config loading, scheduling, dispatch, and sinks are external mechanisms around the weather domain model and normalization logic.
|
- Hexagonal boundaries: provider APIs, config loading, scheduling, dispatch, and sinks are external mechanisms around the weather domain model and normalization logic.
|
||||||
@@ -57,6 +78,23 @@ Tests and examples:
|
|||||||
- The sample `cmd/weatherfeeder/config.yml` is executable test input and is load-tested.
|
- The sample `cmd/weatherfeeder/config.yml` is executable test input and is load-tested.
|
||||||
- Tests should keep exercising package contracts directly rather than relying only on full-daemon execution.
|
- Tests should keep exercising package contracts directly rather than relying only on full-daemon execution.
|
||||||
|
|
||||||
|
## Feedkit Boundary
|
||||||
|
|
||||||
|
`feedkit` provides reusable daemon infrastructure. `weatherfeeder` provides the
|
||||||
|
weather-domain adapters, models, schemas, and mapping policy.
|
||||||
|
|
||||||
|
| Area | Feedkit owns | Weatherfeeder owns |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Config | Generic YAML shape: sources, sinks, routes, modes, cadence, and params. | Driver-specific config rules such as NWS `user_agent`, OpenWeather `units=metric`, and SPC coordinates. |
|
||||||
|
| Events | Domain-agnostic event envelope: ID, kind, source, emitted/effective times, schema, and payload. | Event kind meaning, schema strings, and canonical weather payloads. |
|
||||||
|
| Sources | Source interfaces, registry, expected-kind validation, HTTP helper, and default event ID helper. | NWS/Open-Meteo/OpenWeather/SPC source drivers and raw schema emission. |
|
||||||
|
| Processing | Processor registry, normalize processor, dedupe processor, and pipeline execution. | Weather normalizers and schema-specific raw-to-canonical mapping. |
|
||||||
|
| Dispatch | Route compilation and sink fanout mechanics. | Which weather event kinds are configured and meaningful. |
|
||||||
|
| Sinks | Generic stdout, NATS, and Postgres sink mechanics. | Weather-specific Postgres schema and canonical event-to-row mapping. |
|
||||||
|
|
||||||
|
Do not move weather-domain policy into `feedkit`, and do not duplicate generic
|
||||||
|
daemon mechanics in `weatherfeeder` when feedkit already provides the boundary.
|
||||||
|
|
||||||
## Modules Or Processing Steps
|
## Modules Or Processing Steps
|
||||||
|
|
||||||
The implemented processing steps are source polling, normalization, dedupe, and sink dispatch.
|
The implemented processing steps are source polling, normalization, dedupe, and sink dispatch.
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ docs, not here.
|
|||||||
normalizers.
|
normalizers.
|
||||||
- `internal/sinks/postgres/`: weatherfeeder-owned Postgres schema and canonical
|
- `internal/sinks/postgres/`: weatherfeeder-owned Postgres schema and canonical
|
||||||
event mapper.
|
event mapper.
|
||||||
- `docs/`: current behavior, policies, integration contracts, and roadmap files.
|
- `docs/`: current behavior, consumer guides, integration contracts, policies,
|
||||||
|
and roadmap files.
|
||||||
- `examples/`: maintained, copyable configuration examples.
|
- `examples/`: maintained, copyable configuration examples.
|
||||||
|
|
||||||
## Build And Test
|
## Build And Test
|
||||||
@@ -70,6 +71,32 @@ Use fixtures, local test servers, and package-level tests.
|
|||||||
- Prefer explicit registries and small package-level constructors over hidden
|
- Prefer explicit registries and small package-level constructors over hidden
|
||||||
global behavior.
|
global behavior.
|
||||||
|
|
||||||
|
## Architecture-Preserving Changes
|
||||||
|
|
||||||
|
When changing `weatherfeeder`, preserve the split between feedkit
|
||||||
|
infrastructure and weather-domain behavior.
|
||||||
|
|
||||||
|
Do:
|
||||||
|
|
||||||
|
- keep generic scheduling, dispatch, processor, config, and sink mechanics in
|
||||||
|
feedkit;
|
||||||
|
- keep weather provider rules in source adapters, provider helpers, and
|
||||||
|
normalizers;
|
||||||
|
- keep canonical weather payloads in `model` and schema/wire identifiers in
|
||||||
|
`standards`;
|
||||||
|
- keep Postgres table and row mapping under `internal/sinks/postgres`;
|
||||||
|
- use explicit registries for built-in sources and normalizers.
|
||||||
|
|
||||||
|
Do not:
|
||||||
|
|
||||||
|
- move provider parsing, WMO mapping, or canonical weather policy into
|
||||||
|
`cmd/weatherfeeder`;
|
||||||
|
- move weather-specific constants, schemas, or validation rules into feedkit;
|
||||||
|
- put database column metadata or sink-specific tags on canonical model structs;
|
||||||
|
- replace explicit registries with dynamic plugin loading;
|
||||||
|
- introduce broad abstractions when a small provider-specific helper preserves
|
||||||
|
clarity.
|
||||||
|
|
||||||
## Dependency Policy
|
## Dependency Policy
|
||||||
|
|
||||||
Prefer the Go standard library unless a dependency materially improves
|
Prefer the Go standard library unless a dependency materially improves
|
||||||
@@ -183,6 +210,7 @@ When behavior changes, update the canonical docs in the same change:
|
|||||||
- CLI behavior: `docs/cli.md`;
|
- CLI behavior: `docs/cli.md`;
|
||||||
- operations and recovery: `docs/operations.md`;
|
- operations and recovery: `docs/operations.md`;
|
||||||
- troubleshooting: `docs/troubleshooting.md`;
|
- troubleshooting: `docs/troubleshooting.md`;
|
||||||
|
- public Go package consumption: `docs/consumers/`;
|
||||||
- external contracts: `docs/integrations/`;
|
- external contracts: `docs/integrations/`;
|
||||||
- internal component behavior: `docs/internal/`;
|
- internal component behavior: `docs/internal/`;
|
||||||
- copyable configs: `examples/`.
|
- copyable configs: `examples/`.
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Project documentation must help four audiences:
|
Project documentation must help five audiences:
|
||||||
|
|
||||||
1. users who need to run the application;
|
1. users who need to run the application;
|
||||||
2. administrators/operators who need to configure and operate it;
|
2. administrators/operators who need to configure and operate it;
|
||||||
3. developers who need to understand and change it safely;
|
3. developers who need to understand and change it safely;
|
||||||
4. LLM coding agents that need clear scope, boundaries, and invariants.
|
4. LLM coding agents that need clear scope, boundaries, and invariants;
|
||||||
|
5. developers and LLM coding agents integrating this project from another codebase.
|
||||||
|
|
||||||
Docs should be accurate, concise, task-oriented, and organized by audience. Prefer links to canonical docs over repetition.
|
Docs should be accurate, concise, task-oriented, and organized by audience. Prefer links to canonical docs over repetition.
|
||||||
|
|
||||||
@@ -42,11 +43,14 @@ Canonical homes:
|
|||||||
|
|
||||||
- project purpose and quickstart: `README.md`
|
- project purpose and quickstart: `README.md`
|
||||||
- development principles: `docs/policy/architecture.md`
|
- development principles: `docs/policy/architecture.md`
|
||||||
|
- public HTTP API reference: `docs/api.md`
|
||||||
- configuration reference: `docs/config.md`
|
- configuration reference: `docs/config.md`
|
||||||
- CLI reference: `docs/cli.md`
|
- CLI reference: `docs/cli.md`
|
||||||
- operations and recovery: `docs/operations.md`
|
- operations and recovery: `docs/operations.md`
|
||||||
- troubleshooting: `docs/troubleshooting.md`
|
- troubleshooting: `docs/troubleshooting.md`
|
||||||
|
- public API/package consumer guidance: `docs/consumers/`
|
||||||
- implemented internals: `docs/internal/`
|
- implemented internals: `docs/internal/`
|
||||||
|
- external protocol, service, and file-format contracts: `docs/integrations/`
|
||||||
- future work: `docs/roadmap/`
|
- future work: `docs/roadmap/`
|
||||||
- contributor workflow: `docs/policy/development.md`
|
- contributor workflow: `docs/policy/development.md`
|
||||||
- copyable examples: `examples/`
|
- copyable examples: `examples/`
|
||||||
@@ -106,7 +110,7 @@ Recommended:
|
|||||||
- `examples/`
|
- `examples/`
|
||||||
- `docs/policy/development.md`
|
- `docs/policy/development.md`
|
||||||
|
|
||||||
### Modular, staged, service-oriented, or orchestration application
|
### Modular, service-oriented, or orchestration application
|
||||||
|
|
||||||
Required:
|
Required:
|
||||||
- `docs/cli.md`, if CLI-based
|
- `docs/cli.md`, if CLI-based
|
||||||
@@ -119,6 +123,31 @@ Recommended:
|
|||||||
- `docs/troubleshooting.md`
|
- `docs/troubleshooting.md`
|
||||||
- validated examples under `examples/`
|
- validated examples under `examples/`
|
||||||
|
|
||||||
|
### Public HTTP API service
|
||||||
|
|
||||||
|
Required:
|
||||||
|
- `docs/api.md`
|
||||||
|
- `docs/cli.md`, if CLI-based
|
||||||
|
- `docs/config.md`, if config-driven
|
||||||
|
- `docs/operations.md`
|
||||||
|
- `docs/internal/`
|
||||||
|
- `docs/policy/development.md`
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
- `docs/troubleshooting.md`
|
||||||
|
- `docs/consumers/`, for task-oriented client integration guides
|
||||||
|
- `docs/integrations/`, for upstream/downstream service contracts
|
||||||
|
- validated examples under `examples/`
|
||||||
|
|
||||||
|
### Project with public packages or consumer APIs
|
||||||
|
|
||||||
|
Required:
|
||||||
|
- `docs/consumers/api.md`
|
||||||
|
- one `docs/consumers/pkg-<name>.md` file per public package, if public packages exist
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
- copyable consumer examples under `examples/`, if practical
|
||||||
|
|
||||||
## Required Documents
|
## Required Documents
|
||||||
|
|
||||||
### README.md
|
### README.md
|
||||||
@@ -161,6 +190,32 @@ It should include:
|
|||||||
|
|
||||||
For small projects, this file may be brief. It may simply state that the project is intentionally narrow, monolithic, and dependency-light.
|
For small projects, this file may be brief. It may simply state that the project is intentionally narrow, monolithic, and dependency-light.
|
||||||
|
|
||||||
|
### docs/api.md
|
||||||
|
|
||||||
|
**Audience:** external HTTP API consumers, developers, LLM coding agents integrating by HTTP
|
||||||
|
|
||||||
|
Required for projects whose primary public interface is HTTP.
|
||||||
|
|
||||||
|
`docs/api.md` is the canonical public HTTP API contract. It should be normative for external consumers and should not be duplicated by README, operations docs, consumer guides, or integration docs.
|
||||||
|
|
||||||
|
It should include:
|
||||||
|
|
||||||
|
1. base URL conventions;
|
||||||
|
2. authentication and authorization behavior, if implemented;
|
||||||
|
3. response envelope;
|
||||||
|
4. supported media types and content negotiation behavior;
|
||||||
|
5. shared query parameters;
|
||||||
|
6. endpoint reference grouped by route family;
|
||||||
|
7. request parameters and validation rules;
|
||||||
|
8. response fields, units, nullability, and optionality;
|
||||||
|
9. error response shape and status codes;
|
||||||
|
10. pagination, caching, rate-limit, idempotency, and retry behavior, if implemented;
|
||||||
|
11. compact request and response examples.
|
||||||
|
|
||||||
|
It must document only implemented endpoints and behavior. Planned endpoints, proposed fields, future filters, and experimental response shapes belong only under `docs/roadmap/`.
|
||||||
|
|
||||||
|
For HTTP API projects, `docs/consumers/` may provide task-oriented client integration guides, but those guides should link to `docs/api.md` for the authoritative endpoint contract.
|
||||||
|
|
||||||
### docs/policy/development.md
|
### docs/policy/development.md
|
||||||
|
|
||||||
**Audience:** developers, LLM coding agents
|
**Audience:** developers, LLM coding agents
|
||||||
@@ -175,7 +230,7 @@ It should include:
|
|||||||
- dependency policy;
|
- dependency policy;
|
||||||
- how to add config fields;
|
- how to add config fields;
|
||||||
- how to add CLI flags;
|
- how to add CLI flags;
|
||||||
- how to add stages/modules/adapters, if applicable;
|
- how to add modules or adapters, if applicable;
|
||||||
- how to update examples;
|
- how to update examples;
|
||||||
- documentation update expectations.
|
- documentation update expectations.
|
||||||
|
|
||||||
@@ -216,7 +271,7 @@ Explain when commands are useful, not just their syntax.
|
|||||||
|
|
||||||
**Audience:** administrators, operators
|
**Audience:** administrators, operators
|
||||||
|
|
||||||
Required for applications that maintain state, support resume behavior, run multiple stages, write durable artifacts, use remote storage, or require recovery procedures.
|
Required for applications that maintain state, support resume behavior, run multi-step workflows, write durable artifacts, use remote storage, or require recovery procedures.
|
||||||
|
|
||||||
It should cover:
|
It should cover:
|
||||||
|
|
||||||
@@ -244,11 +299,40 @@ Each entry should include:
|
|||||||
- safe fix;
|
- safe fix;
|
||||||
- relevant links.
|
- relevant links.
|
||||||
|
|
||||||
|
### docs/consumers/
|
||||||
|
|
||||||
|
**Audience:** developers and LLM coding agents integrating this project from another codebase
|
||||||
|
|
||||||
|
Required for projects with public packages, SDKs, client APIs, plugin APIs, or other application-facing integration surfaces.
|
||||||
|
|
||||||
|
This directory describes how an external codebase should consume the project's public API. It should be task-oriented and copyable where useful. It is not the place for internal implementation details or operator procedures.
|
||||||
|
|
||||||
|
For projects whose public API is HTTP, `docs/consumers/` is not required, and it should not duplicate the endpoint reference in `docs/api.md`. If present, it may provide practical integration workflows, client-specific examples, or migration notes that link back to `docs/api.md`.
|
||||||
|
|
||||||
|
`docs/consumers/api.md` should provide the consumer-facing overview and primary implementation workflow. It should include:
|
||||||
|
|
||||||
|
1. intended consumer audience and use cases;
|
||||||
|
2. required inputs supplied by operators or deployment configuration;
|
||||||
|
3. recommended public package or API workflow;
|
||||||
|
4. minimal copyable example;
|
||||||
|
5. consumer responsibilities and boundaries;
|
||||||
|
6. retry, idempotency, or status behavior, if applicable;
|
||||||
|
7. links to package-specific docs and canonical integration contracts.
|
||||||
|
|
||||||
|
Package-specific docs should be named `pkg-<name>.md` and should include:
|
||||||
|
|
||||||
|
1. import path;
|
||||||
|
2. intended use cases;
|
||||||
|
3. primary types and functions needed by consumers;
|
||||||
|
4. minimal examples;
|
||||||
|
5. validation, error, retry, and boundary behavior;
|
||||||
|
6. links to canonical file-format or wire-protocol contracts.
|
||||||
|
|
||||||
### docs/internal/
|
### docs/internal/
|
||||||
|
|
||||||
**Audience:** developers, LLM coding agents
|
**Audience:** developers, LLM coding agents
|
||||||
|
|
||||||
Required for modular, staged, service-oriented, or orchestration projects.
|
Required for modular, service-oriented, or orchestration projects.
|
||||||
|
|
||||||
This directory describes implemented internal components. It is not the roadmap.
|
This directory describes implemented internal components. It is not the roadmap.
|
||||||
|
|
||||||
@@ -289,7 +373,9 @@ Roadmap docs should not be confused with current behavior.
|
|||||||
|
|
||||||
Required for projects that depend on external CLIs, APIs, services, protocols, or file formats where the integration contract is important to maintain.
|
Required for projects that depend on external CLIs, APIs, services, protocols, or file formats where the integration contract is important to maintain.
|
||||||
|
|
||||||
This directory contains concise, versioned reference notes for external integration contracts. It should document only the parts of the external system that this project actually uses.
|
This directory contains concise, versioned reference notes for external integration contracts. It should document only the parts of the external system that this project actually uses or exposes.
|
||||||
|
|
||||||
|
For public HTTP API services, `docs/integrations/` should document upstream, downstream, storage, protocol, or runtime contracts that the service depends on or bridges. It should not become a second copy of the public HTTP endpoint reference; that belongs in `docs/api.md`.
|
||||||
|
|
||||||
Use one file per integration where useful.
|
Use one file per integration where useful.
|
||||||
|
|
||||||
@@ -346,8 +432,10 @@ Before merging documentation changes, verify:
|
|||||||
|
|
||||||
- README is concise and orientation-focused.
|
- README is concise and orientation-focused.
|
||||||
- `docs/policy/architecture.md` describes development principles.
|
- `docs/policy/architecture.md` describes development principles.
|
||||||
|
- `docs/api.md` is the canonical HTTP contract for HTTP API services.
|
||||||
- Future work appears only under `docs/roadmap/`.
|
- Future work appears only under `docs/roadmap/`.
|
||||||
- User-facing docs avoid unnecessary internals.
|
- User-facing docs avoid unnecessary internals.
|
||||||
|
- Consumer-facing docs explain public APIs without duplicating HTTP endpoint or integration contracts.
|
||||||
- Developer-facing docs preserve boundaries and invariants.
|
- Developer-facing docs preserve boundaries and invariants.
|
||||||
- Config examples match the schema.
|
- Config examples match the schema.
|
||||||
- CLI examples match real commands and flags.
|
- CLI examples match real commands and flags.
|
||||||
|
|||||||
60
docs/roadmap/future.md
Normal file
60
docs/roadmap/future.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Future Work
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document is the catch-all roadmap for planned, deferred, aspirational, experimental, or unimplemented weatherfeeder work. Current behavior belongs in the canonical docs outside `docs/roadmap/`.
|
||||||
|
|
||||||
|
## SPC Convective Outlook Follow-Ups
|
||||||
|
|
||||||
|
### Weatherapi Outlook Endpoints
|
||||||
|
|
||||||
|
Expose persisted SPC convective outlooks through `weatherapi` after the weatherfeeder storage contract is stable.
|
||||||
|
|
||||||
|
Likely endpoints:
|
||||||
|
|
||||||
|
- `GET /outlooks/convective`
|
||||||
|
- `GET /outlooks/convective/active`
|
||||||
|
- `GET /outlooks/convective/location`
|
||||||
|
|
||||||
|
Recommended behavior:
|
||||||
|
|
||||||
|
- Return the latest outlook run by default.
|
||||||
|
- Support active outlook filtering by current time and `containsLocation=true`.
|
||||||
|
- Consider optional query filters for `day`, `outlookType`, and `containsLocation`.
|
||||||
|
- Preserve canonical outlook geometry for downstream display and audit use.
|
||||||
|
|
||||||
|
### SPC Day 4-8 Outlooks
|
||||||
|
|
||||||
|
Add SPC Day 4-8 convective outlook support as a schema-compatible extension only after Day 1-3 operation is proven.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Day 4-8 products have different semantics from Day 1-3 categorical/tornado/hail/wind products.
|
||||||
|
- Avoid forcing Day 4-8 assumptions into the current Day 1-3 model until the source shapes and consumer needs are reviewed.
|
||||||
|
- Prefer reusing `weather.outlook.v1` if the fields remain accurate; otherwise write a separate roadmap before changing the canonical contract.
|
||||||
|
|
||||||
|
### Degraded SPC Bundle Mode
|
||||||
|
|
||||||
|
Evaluate whether the SPC source should support degraded partial bundles when one required upstream product fails.
|
||||||
|
|
||||||
|
Current behavior should remain atomic: if a required GeoJSON or print-page fetch fails, emit no event for that poll.
|
||||||
|
|
||||||
|
Future degraded mode would need a clear contract for:
|
||||||
|
|
||||||
|
- distinguishing "no risk polygon" from "product missing";
|
||||||
|
- exposing per-product fetch errors without leaking raw provider internals into canonical events;
|
||||||
|
- deciding whether downstream sinks and APIs should store or serve partial snapshots.
|
||||||
|
|
||||||
|
### Richer SPC Page Assets And Tables
|
||||||
|
|
||||||
|
Evaluate whether to parse additional SPC print-page metadata beyond the current discussion text.
|
||||||
|
|
||||||
|
Possible additions:
|
||||||
|
|
||||||
|
- archive GeoJSON/shapefile/KML links;
|
||||||
|
- image URLs;
|
||||||
|
- page risk tables;
|
||||||
|
- city tables;
|
||||||
|
- richer discussion section metadata.
|
||||||
|
|
||||||
|
Keep GeoJSON products authoritative for polygons, validity windows, and point matching unless a future roadmap explicitly changes that contract.
|
||||||
@@ -1,697 +0,0 @@
|
|||||||
# SPC Convective Outlook Implementation Plan
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Implement `weatherfeeder` support for Storm Prediction Center Day 1-3 convective outlooks described in [`docs/roadmap/spc.md`](spc.md). This plan is written for an LLM coding agent and should be followed stage by stage.
|
|
||||||
|
|
||||||
This is a planning document only. The implementation must preserve the existing weatherfeeder architecture: sources emit raw provider events, normalizers map raw payloads into canonical model types, and sinks persist canonical schemas.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
The following choices are fixed for this implementation:
|
|
||||||
|
|
||||||
- Scope is Day 1-3 SPC convective outlooks only.
|
|
||||||
- Day 4-8 outlooks are out of scope.
|
|
||||||
- GeoJSON files are authoritative for polygons, validity windows, issue times, outlook labels, and severity ranking.
|
|
||||||
- Day 1-3 print pages are authoritative for discussion text.
|
|
||||||
- RSS is optional supplemental metadata only and must not be required for correctness.
|
|
||||||
- Do not fetch RSS by default. Include RSS only when an optional `rss_url` source param is configured.
|
|
||||||
- A poll is atomic for required products. If any configured GeoJSON or print-page URL fails or returns a non-2xx response, return an error and emit no event.
|
|
||||||
- Use compact GeoJSON geometry in the canonical payload for auditability and downstream display.
|
|
||||||
- Use standard-library-first parsing. Do not add an HTML parsing dependency unless string-based extraction proves unmaintainable during implementation.
|
|
||||||
- Keep all new planned behavior inside `weatherfeeder`; do not make `weatherapi` changes in this pass.
|
|
||||||
|
|
||||||
## Public Contract
|
|
||||||
|
|
||||||
Add schema constants in `standards/schema.go`:
|
|
||||||
|
|
||||||
- `SchemaRawSPCConvectiveOutlookV1 = "raw.spc.convective_outlook.v1"`
|
|
||||||
- `SchemaWeatherOutlookV1 = "weather.outlook.v1"`
|
|
||||||
|
|
||||||
Add source driver:
|
|
||||||
|
|
||||||
- `spc_convective_outlook`
|
|
||||||
|
|
||||||
Add event kind:
|
|
||||||
|
|
||||||
- `outlook`
|
|
||||||
|
|
||||||
Add canonical model types:
|
|
||||||
|
|
||||||
- `model.WeatherOutlookRun`
|
|
||||||
- `model.WeatherOutlook`
|
|
||||||
|
|
||||||
Canonical run fields:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type WeatherOutlookRun struct {
|
|
||||||
LocationID string `json:"locationId,omitempty"`
|
|
||||||
LocationName string `json:"locationName,omitempty"`
|
|
||||||
Latitude *float64 `json:"latitude,omitempty"`
|
|
||||||
Longitude *float64 `json:"longitude,omitempty"`
|
|
||||||
AsOf time.Time `json:"asOf"`
|
|
||||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
|
||||||
Outlooks []WeatherOutlook `json:"outlooks"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Canonical outlook fields:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type WeatherOutlook struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
Product string `json:"product"`
|
|
||||||
Day int `json:"day"`
|
|
||||||
OutlookType string `json:"outlookType"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
LabelText string `json:"labelText,omitempty"`
|
|
||||||
SeverityRank *int `json:"severityRank,omitempty"`
|
|
||||||
ValidFrom time.Time `json:"validFrom"`
|
|
||||||
ValidTo time.Time `json:"validTo"`
|
|
||||||
IssuedAt time.Time `json:"issuedAt"`
|
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
|
||||||
Forecaster string `json:"forecaster,omitempty"`
|
|
||||||
Headline string `json:"headline,omitempty"`
|
|
||||||
Summary string `json:"summary,omitempty"`
|
|
||||||
Discussion string `json:"discussion,omitempty"`
|
|
||||||
SourceURL string `json:"sourceUrl,omitempty"`
|
|
||||||
ImageURL string `json:"imageUrl,omitempty"`
|
|
||||||
ContainsLocation bool `json:"containsLocation"`
|
|
||||||
Geometry json.RawMessage `json:"geometry"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Required canonical fields:
|
|
||||||
|
|
||||||
- Run: `asOf`, `outlooks`.
|
|
||||||
- Outlook: `id`, `provider`, `product`, `day`, `outlookType`, `label`, `validFrom`, `validTo`, `issuedAt`, `expiresAt`, `containsLocation`, `geometry`.
|
|
||||||
|
|
||||||
Canonical values:
|
|
||||||
|
|
||||||
- `provider` is `spc`.
|
|
||||||
- `product` is `convective`.
|
|
||||||
- `outlookType` is one of `categorical`, `tornado`, `hail`, `wind`.
|
|
||||||
- `day` is one of `1`, `2`, `3`.
|
|
||||||
|
|
||||||
## Source Inputs
|
|
||||||
|
|
||||||
Default required GeoJSON products:
|
|
||||||
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_cat.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_torn.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_hail.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_wind.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_cat.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_torn.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_hail.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_wind.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_cat.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_torn.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_hail.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_wind.nolyr.geojson`
|
|
||||||
|
|
||||||
Default required print-page products:
|
|
||||||
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_prt.html`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_prt.html`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_prt.html`
|
|
||||||
|
|
||||||
Optional RSS product:
|
|
||||||
|
|
||||||
- `https://www.spc.noaa.gov/products/spcacrss.xml`
|
|
||||||
|
|
||||||
Recommended config shape:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: SPCConvectiveOutlookSTL
|
|
||||||
mode: poll
|
|
||||||
kinds: ["outlook"]
|
|
||||||
driver: spc_convective_outlook
|
|
||||||
every: 30m
|
|
||||||
params:
|
|
||||||
latitude: 38.6239
|
|
||||||
longitude: -90.3571
|
|
||||||
location_id: "stl"
|
|
||||||
location_name: "St. Louis, MO"
|
|
||||||
user_agent: "HomeOps (eric@maximumdirect.net)"
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional source params:
|
|
||||||
|
|
||||||
- `geojson_urls`: map from product key to URL, used by tests and future upstream changes.
|
|
||||||
- `discussion_urls`: map from day key to URL, used by tests and future upstream changes.
|
|
||||||
- `rss_url`: string; when non-empty, fetch RSS as supplemental metadata.
|
|
||||||
|
|
||||||
Product keys for `geojson_urls`:
|
|
||||||
|
|
||||||
- `day1_categorical`, `day1_tornado`, `day1_hail`, `day1_wind`
|
|
||||||
- `day2_categorical`, `day2_tornado`, `day2_hail`, `day2_wind`
|
|
||||||
- `day3_categorical`, `day3_tornado`, `day3_hail`, `day3_wind`
|
|
||||||
|
|
||||||
Discussion keys for `discussion_urls`:
|
|
||||||
|
|
||||||
- `day1`, `day2`, `day3`
|
|
||||||
|
|
||||||
## Raw Bundle Shape
|
|
||||||
|
|
||||||
Create a provider raw bundle type under `internal/providers/spc` or `internal/normalizers/spc` and use it consistently between source tests and normalizer tests. Prefer `internal/providers/spc` if source metadata extraction and normalizer parsing share helpers.
|
|
||||||
|
|
||||||
Suggested raw payload shape:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type RawConvectiveOutlookBundle struct {
|
|
||||||
LocationID string `json:"locationId,omitempty"`
|
|
||||||
LocationName string `json:"locationName,omitempty"`
|
|
||||||
Latitude float64 `json:"latitude"`
|
|
||||||
Longitude float64 `json:"longitude"`
|
|
||||||
FetchedAt time.Time `json:"fetchedAt"`
|
|
||||||
Products []RawOutlookProduct `json:"products"`
|
|
||||||
Discussions []RawDiscussionPage `json:"discussions"`
|
|
||||||
RSS *RawRSSFeed `json:"rss,omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
type RawOutlookProduct struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Day int `json:"day"`
|
|
||||||
OutlookType string `json:"outlookType"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
FetchedAt time.Time `json:"fetchedAt"`
|
|
||||||
Body json.RawMessage `json:"body"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
type RawDiscussionPage struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Day int `json:"day"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
FetchedAt time.Time `json:"fetchedAt"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
type RawRSSFeed struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
FetchedAt time.Time `json:"fetchedAt"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not put parsed canonical fields into the raw bundle except configured metadata and product keys needed to identify each fetched upstream document. Source-level timestamp parsing is allowed only for event `effectiveAt` selection.
|
|
||||||
|
|
||||||
## Stage 1: Provider Helpers And Fixtures
|
|
||||||
|
|
||||||
Goal: add deterministic SPC parsing primitives and test fixtures before wiring the source or normalizer.
|
|
||||||
|
|
||||||
Files to add:
|
|
||||||
|
|
||||||
- `internal/providers/spc/doc.go`
|
|
||||||
- `internal/providers/spc/time.go`
|
|
||||||
- `internal/providers/spc/product.go`
|
|
||||||
- `internal/providers/spc/geojson.go`
|
|
||||||
- `internal/providers/spc/discussion.go`
|
|
||||||
- `internal/providers/spc/rss.go`, only if optional RSS parsing is implemented
|
|
||||||
- `internal/providers/spc/testdata/day1_cat.geojson`
|
|
||||||
- `internal/providers/spc/testdata/day2_torn.geojson`
|
|
||||||
- `internal/providers/spc/testdata/day3_wind.geojson`
|
|
||||||
- `internal/providers/spc/testdata/day1_prt.html`
|
|
||||||
- `internal/providers/spc/testdata/day2_prt_corr.html`
|
|
||||||
- `internal/providers/spc/testdata/day3_prt.html`
|
|
||||||
|
|
||||||
Provider helper behavior:
|
|
||||||
|
|
||||||
- Define stable product metadata for the 12 required GeoJSON products.
|
|
||||||
- Define stable discussion metadata for the 3 required print-page products.
|
|
||||||
- Parse SPC ISO timestamps from GeoJSON properties using `time.Parse(time.RFC3339, value)` after trimming whitespace.
|
|
||||||
- Decode enough GeoJSON to expose feature properties and raw geometry without owning canonical mapping.
|
|
||||||
- Preserve raw geometry as compact JSON bytes for later canonical use.
|
|
||||||
- Extract print-page product text from the first useful `<pre>` block.
|
|
||||||
- Strip embedded `<script>` blocks and remaining tags from extracted `<pre>` content.
|
|
||||||
- Use `html.UnescapeString`, normalize CRLF to LF, and trim surrounding blank lines.
|
|
||||||
- Parse print-page `Updated:` timestamps when present.
|
|
||||||
- Parse discussion headline/product title from product text.
|
|
||||||
- Parse `...SUMMARY...` content through the next section heading.
|
|
||||||
- Preserve full product text as discussion text after cleanup.
|
|
||||||
- Preserve correction markers such as `CORR 1` in headline or metadata when present.
|
|
||||||
|
|
||||||
Dependency rule:
|
|
||||||
|
|
||||||
- Start with `strings`, `regexp`, `encoding/json`, `encoding/xml`, and `html` from the standard library.
|
|
||||||
- Do not add `golang.org/x/net/html` or another HTML parser unless tests show the string extraction is too brittle.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- `go test ./internal/providers/spc`
|
|
||||||
- Product metadata contains exactly 12 GeoJSON products in day/type order.
|
|
||||||
- Discussion metadata contains exactly 3 print pages in day order.
|
|
||||||
- GeoJSON fixture decode exposes `VALID_ISO`, `EXPIRE_ISO`, `ISSUE_ISO`, `FORECASTER`, `LABEL`, `LABEL2`, `DN`, and geometry.
|
|
||||||
- Print-page fixture extraction returns product text without scripts or tags.
|
|
||||||
- Day 2 correction fixture preserves `CORR 1`.
|
|
||||||
- Summary extraction returns only the summary paragraph content.
|
|
||||||
- Updated timestamp parser returns UTC time when present and nil when absent.
|
|
||||||
|
|
||||||
Stage completion criteria:
|
|
||||||
|
|
||||||
- Provider helper tests pass.
|
|
||||||
- No source, normalizer, model, standards, sink, config, or docs current-behavior files are changed in this stage unless needed for package compilation.
|
|
||||||
|
|
||||||
## Stage 2: Geometry Helper
|
|
||||||
|
|
||||||
Goal: implement point-in-polygon support independent of SPC parsing.
|
|
||||||
|
|
||||||
Files to add:
|
|
||||||
|
|
||||||
- `internal/geo/geojson.go`
|
|
||||||
- `internal/geo/point.go`
|
|
||||||
- `internal/geo/point_test.go`
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- Support GeoJSON `Polygon` and `MultiPolygon` only.
|
|
||||||
- Accept geometry as `json.RawMessage` or `[]byte`.
|
|
||||||
- Interpret GeoJSON coordinate order as `[longitude, latitude]`.
|
|
||||||
- Treat the first ring as the exterior ring.
|
|
||||||
- Treat subsequent rings as holes.
|
|
||||||
- Count boundary points as inside.
|
|
||||||
- Return a clear error for unsupported geometry types, malformed coordinates, empty rings, or invalid JSON.
|
|
||||||
- Use planar ray casting. This is sufficient for operational point-in-polygon checks at SPC polygon scale.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Point inside simple polygon returns true.
|
|
||||||
- Point outside simple polygon returns false.
|
|
||||||
- Point on polygon boundary returns true.
|
|
||||||
- Point in a hole returns false.
|
|
||||||
- Point inside one `MultiPolygon` member returns true.
|
|
||||||
- Longitude/latitude ordering regression test fails if coordinates are reversed.
|
|
||||||
- Unsupported geometry returns a useful error.
|
|
||||||
|
|
||||||
Verification:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/geo
|
|
||||||
```
|
|
||||||
|
|
||||||
Stage completion criteria:
|
|
||||||
|
|
||||||
- Geometry helper has no dependency on SPC, source, normalizer, or sink packages.
|
|
||||||
|
|
||||||
## Stage 3: Source Driver And Raw Schema
|
|
||||||
|
|
||||||
Goal: emit raw SPC outlook bundles from a new poll source.
|
|
||||||
|
|
||||||
Files to update:
|
|
||||||
|
|
||||||
- `standards/schema.go`
|
|
||||||
- `internal/sources/builtins.go`
|
|
||||||
- `internal/sources/builtins_test.go`
|
|
||||||
|
|
||||||
Files to add:
|
|
||||||
|
|
||||||
- `internal/sources/spc/convective_outlook.go`
|
|
||||||
- `internal/sources/spc/convective_outlook_test.go`
|
|
||||||
|
|
||||||
Source constructor:
|
|
||||||
|
|
||||||
- Export `NewConvectiveOutlookSource(cfg config.SourceConfig) (*ConvectiveOutlookSource, error)`.
|
|
||||||
- Register driver `spc_convective_outlook` in `internal/sources/builtins.go`.
|
|
||||||
- Validate required params `latitude` and `longitude`.
|
|
||||||
- Accept optional params `location_id`, `location_name`, `geojson_urls`, `discussion_urls`, and `rss_url`.
|
|
||||||
- Require `user_agent` when the existing HTTP source conventions require it.
|
|
||||||
- Advertise `Kinds() []event.Kind{event.Kind("outlook")}`.
|
|
||||||
|
|
||||||
Fetch behavior:
|
|
||||||
|
|
||||||
- Fetch all required GeoJSON URLs and print-page URLs every poll cycle.
|
|
||||||
- Fetch optional RSS only when `rss_url` is configured and non-empty.
|
|
||||||
- Use Accept `application/geo+json, application/json` for GeoJSON requests.
|
|
||||||
- Use Accept `text/html, application/xhtml+xml` for print pages.
|
|
||||||
- Use Accept `application/rss+xml, application/xml, text/xml` for RSS.
|
|
||||||
- Respect `context.Context` on all requests.
|
|
||||||
- Do not emit partial bundles.
|
|
||||||
- If the complete raw bundle is unchanged from the previous successful poll, return no events. Implement source-local change detection by hashing the combined fetched bodies if feedkit HTTP conditional helpers do not directly support multi-document polling.
|
|
||||||
- Build the raw bundle with fetched bodies and metadata.
|
|
||||||
- Emit one raw event with kind `outlook` and schema `standards.SchemaRawSPCConvectiveOutlookV1`.
|
|
||||||
- Use `fksources.DefaultEventID("", sourceName, effectiveAt, emittedAt)`.
|
|
||||||
|
|
||||||
Effective time policy:
|
|
||||||
|
|
||||||
- Prefer latest valid `ISSUE_ISO` across all GeoJSON features.
|
|
||||||
- Fallback to latest valid print-page `Updated:` timestamp.
|
|
||||||
- Fallback to RSS `lastBuildDate` if RSS was fetched and parseable.
|
|
||||||
- Fallback to fetch time if no provider timestamp is parseable.
|
|
||||||
- Keep fetch-time fallback as the last source effective-time option so downstream consumers still receive a stable effective time when provider metadata is absent.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Builtin registry builds `spc_convective_outlook` as a `PollSource`.
|
|
||||||
- `Kinds()` returns `outlook`.
|
|
||||||
- Constructor rejects missing `latitude` or `longitude`.
|
|
||||||
- Poll emits one raw event with schema `raw.spc.convective_outlook.v1`.
|
|
||||||
- Raw bundle includes 12 products and 3 discussions when defaults are overridden to test-server URLs.
|
|
||||||
- Poll effectiveAt prefers latest GeoJSON `ISSUE_ISO`.
|
|
||||||
- Poll effectiveAt falls back to print-page `Updated:` when GeoJSON issue times are absent.
|
|
||||||
- Poll includes RSS only when `rss_url` is configured.
|
|
||||||
- Unchanged response emits no events on a second poll.
|
|
||||||
- A configured GeoJSON fetch failure returns error and emits no event.
|
|
||||||
- A configured print-page fetch failure returns error and emits no event.
|
|
||||||
- Tests use `httptest.Server`; do not call live SPC services.
|
|
||||||
|
|
||||||
Verification:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/providers/spc ./internal/sources
|
|
||||||
```
|
|
||||||
|
|
||||||
Stage completion criteria:
|
|
||||||
|
|
||||||
- Source emits raw bundles only.
|
|
||||||
- No canonical model, normalizer, or sink mapping is added in this stage except schema constants required for compilation.
|
|
||||||
|
|
||||||
## Stage 4: Canonical Model And Normalizer
|
|
||||||
|
|
||||||
Goal: convert raw SPC bundles into `weather.outlook.v1` events.
|
|
||||||
|
|
||||||
Files to update:
|
|
||||||
|
|
||||||
- `standards/schema.go`
|
|
||||||
- `internal/normalizers/builtins_test.go`
|
|
||||||
|
|
||||||
Files to add:
|
|
||||||
|
|
||||||
- `model/outlook.go`
|
|
||||||
- `internal/normalizers/spc/convective_outlook.go`
|
|
||||||
- `internal/normalizers/spc/register.go`
|
|
||||||
- `internal/normalizers/spc/convective_outlook_test.go`
|
|
||||||
|
|
||||||
Files to update for registration:
|
|
||||||
|
|
||||||
- `internal/normalizers/builtins.go`
|
|
||||||
|
|
||||||
Normalizer behavior:
|
|
||||||
|
|
||||||
- Add `SPCConvectiveOutlookNormalizer`.
|
|
||||||
- Match only `standards.SchemaRawSPCConvectiveOutlookV1`.
|
|
||||||
- Decode `RawConvectiveOutlookBundle`.
|
|
||||||
- Build one `model.WeatherOutlookRun` per raw bundle.
|
|
||||||
- Map each GeoJSON feature to one `model.WeatherOutlook`.
|
|
||||||
- Preserve feature order within each product.
|
|
||||||
- Order products by day then outlook type in this order: `categorical`, `tornado`, `hail`, `wind`.
|
|
||||||
- Compute `containsLocation` with `internal/geo` using the configured bundle latitude and longitude.
|
|
||||||
- Store compact feature geometry JSON in `WeatherOutlook.Geometry`.
|
|
||||||
- Set output schema to `standards.SchemaWeatherOutlookV1`.
|
|
||||||
- Set output effectiveAt to run `AsOf`.
|
|
||||||
- Use `internal/normalizers/common.Finalize` or existing normalizer helper patterns so envelope handling remains consistent.
|
|
||||||
|
|
||||||
GeoJSON property mapping:
|
|
||||||
|
|
||||||
- `VALID_ISO` maps to `validFrom` and is required.
|
|
||||||
- `EXPIRE_ISO` maps to `validTo` and `expiresAt` and is required.
|
|
||||||
- `ISSUE_ISO` maps to `issuedAt` and is required.
|
|
||||||
- `FORECASTER` maps to `forecaster` and is optional.
|
|
||||||
- `LABEL` maps to `label` and is required.
|
|
||||||
- `LABEL2` maps to `labelText` and is optional.
|
|
||||||
- `DN` maps to `severityRank` and is optional.
|
|
||||||
|
|
||||||
Derived mapping:
|
|
||||||
|
|
||||||
- `provider` is `spc`.
|
|
||||||
- `product` is `convective`.
|
|
||||||
- `day` and `outlookType` come from raw product metadata, not from brittle URL parsing when product metadata is available.
|
|
||||||
- `id` is deterministic: join day, outlook type, normalized label, issuedAt UTC in RFC3339 format, validFrom UTC in RFC3339 format, and product-local feature index. Use a stable ASCII-safe format.
|
|
||||||
- `sourceUrl` is the GeoJSON product URL for geometry/probability features.
|
|
||||||
- `imageUrl` is empty for v1.
|
|
||||||
|
|
||||||
Discussion enrichment:
|
|
||||||
|
|
||||||
- Parse each print-page discussion with `internal/providers/spc` helpers.
|
|
||||||
- Attach Day 1 print-page headline, summary, and discussion to Day 1 outlooks, Day 2 to Day 2 outlooks, and Day 3 to Day 3 outlooks.
|
|
||||||
- Use the same discussion text for categorical, tornado, hail, and wind outlooks for the same day.
|
|
||||||
- If a print page is fetched but discussion extraction fails, normalization should fail because print pages are required v1 inputs.
|
|
||||||
- RSS metadata must not be required for discussion enrichment.
|
|
||||||
|
|
||||||
Run timestamp policy:
|
|
||||||
|
|
||||||
- `AsOf` is latest valid `issuedAt` across all outlook features.
|
|
||||||
- Fallback to latest print-page `Updated:` timestamp.
|
|
||||||
- Fallback to input event `EffectiveAt`.
|
|
||||||
- Fallback to input event `EmittedAt`.
|
|
||||||
- `IssuedAt` is latest valid `issuedAt` across all outlook features when any feature exists; otherwise nil.
|
|
||||||
|
|
||||||
Error behavior:
|
|
||||||
|
|
||||||
- Fail normalization if required GeoJSON timestamps are missing or unparseable.
|
|
||||||
- Fail normalization if required `LABEL` is empty.
|
|
||||||
- Fail normalization if required geometry is missing or invalid.
|
|
||||||
- Fail normalization if latitude or longitude is missing or invalid in the raw bundle.
|
|
||||||
- Include product key and feature index in errors.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Normalizer matches only `raw.spc.convective_outlook.v1`.
|
|
||||||
- Builtin normalizer order includes the SPC normalizer after existing provider normalizers unless a specific order is needed.
|
|
||||||
- Canonical schema is `weather.outlook.v1`.
|
|
||||||
- Categorical fixture maps expected day, type, label, label text, severity rank, valid times, issue time, forecaster, source URL, and geometry.
|
|
||||||
- Probabilistic fixtures for tornado, hail, and wind map expected `outlookType` values.
|
|
||||||
- `containsLocation` is true for a fixture point inside a polygon.
|
|
||||||
- `containsLocation` is false for a fixture point outside a polygon.
|
|
||||||
- Print-page text maps to headline, summary, and discussion.
|
|
||||||
- Day 2 correction marker `CORR 1` is preserved in headline or discussion metadata.
|
|
||||||
- Missing optional RSS still normalizes successfully.
|
|
||||||
- Invalid required timestamp fails with product key and feature index context.
|
|
||||||
- Invalid geometry fails with product key and feature index context.
|
|
||||||
- Output JSON uses the intended field names and does not expose raw bundle internals.
|
|
||||||
|
|
||||||
Verification:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./model ./internal/geo ./internal/providers/spc ./internal/normalizers ./internal/normalizers/spc
|
|
||||||
```
|
|
||||||
|
|
||||||
Stage completion criteria:
|
|
||||||
|
|
||||||
- A raw SPC bundle normalizes into canonical `model.WeatherOutlookRun`.
|
|
||||||
- No Postgres schema or current-behavior docs are updated in this stage unless the canonical schema documentation is intentionally updated with implementation.
|
|
||||||
|
|
||||||
## Stage 5: Postgres Sink
|
|
||||||
|
|
||||||
Goal: persist canonical outlook events through the weatherfeeder Postgres sink.
|
|
||||||
|
|
||||||
Files to update:
|
|
||||||
|
|
||||||
- `internal/sinks/postgres/schema.go`
|
|
||||||
- `internal/sinks/postgres/map.go`
|
|
||||||
- `internal/sinks/postgres/schema_test.go`
|
|
||||||
- `internal/sinks/postgres/map_test.go`
|
|
||||||
- `internal/sinks/postgres/doc.go`
|
|
||||||
|
|
||||||
Tables:
|
|
||||||
|
|
||||||
- `outlook_runs`
|
|
||||||
- `outlooks`
|
|
||||||
|
|
||||||
Add table constants:
|
|
||||||
|
|
||||||
- `tableOutlookRuns = "outlook_runs"`
|
|
||||||
- `tableOutlooks = "outlooks"`
|
|
||||||
|
|
||||||
`outlook_runs` columns:
|
|
||||||
|
|
||||||
- `event_id TEXT NOT NULL`
|
|
||||||
- `event_kind TEXT NOT NULL`
|
|
||||||
- `event_source TEXT NOT NULL`
|
|
||||||
- `event_schema TEXT NOT NULL`
|
|
||||||
- `event_emitted_at TIMESTAMPTZ NOT NULL`
|
|
||||||
- `event_effective_at TIMESTAMPTZ NULL`
|
|
||||||
- `location_id TEXT NULL`
|
|
||||||
- `location_name TEXT NULL`
|
|
||||||
- `latitude DOUBLE PRECISION NULL`
|
|
||||||
- `longitude DOUBLE PRECISION NULL`
|
|
||||||
- `as_of TIMESTAMPTZ NOT NULL`
|
|
||||||
- `issued_at TIMESTAMPTZ NULL`
|
|
||||||
- `outlook_count INTEGER NOT NULL`
|
|
||||||
|
|
||||||
`outlook_runs` keys and indexes:
|
|
||||||
|
|
||||||
- Primary key: `event_id`
|
|
||||||
- Prune column: `as_of`
|
|
||||||
- Index `idx_wf_outlook_run_location_as_of` on `location_id, as_of`
|
|
||||||
- Index `idx_wf_outlook_run_as_of` on `as_of`
|
|
||||||
|
|
||||||
`outlooks` columns:
|
|
||||||
|
|
||||||
- `run_event_id TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE NOT NULL`
|
|
||||||
- `outlook_index INTEGER NOT NULL`
|
|
||||||
- `as_of TIMESTAMPTZ NOT NULL`
|
|
||||||
- `product TEXT NOT NULL`
|
|
||||||
- `day INTEGER NOT NULL`
|
|
||||||
- `outlook_type TEXT NOT NULL`
|
|
||||||
- `label TEXT NOT NULL`
|
|
||||||
- `label_text TEXT NULL`
|
|
||||||
- `severity_rank INTEGER NULL`
|
|
||||||
- `valid_from TIMESTAMPTZ NOT NULL`
|
|
||||||
- `valid_to TIMESTAMPTZ NOT NULL`
|
|
||||||
- `issued_at TIMESTAMPTZ NOT NULL`
|
|
||||||
- `expires_at TIMESTAMPTZ NOT NULL`
|
|
||||||
- `forecaster TEXT NULL`
|
|
||||||
- `headline TEXT NULL`
|
|
||||||
- `summary TEXT NULL`
|
|
||||||
- `discussion TEXT NULL`
|
|
||||||
- `source_url TEXT NULL`
|
|
||||||
- `image_url TEXT NULL`
|
|
||||||
- `contains_location BOOLEAN NOT NULL`
|
|
||||||
- `geometry_json TEXT NOT NULL`
|
|
||||||
|
|
||||||
`outlooks` keys and indexes:
|
|
||||||
|
|
||||||
- Primary key: `run_event_id, outlook_index`
|
|
||||||
- Prune column: `as_of`
|
|
||||||
- Index `idx_wf_outlooks_contains_valid` on `contains_location, valid_from, valid_to`
|
|
||||||
- Index `idx_wf_outlooks_day_type_label` on `day, outlook_type, label`
|
|
||||||
- Index `idx_wf_outlooks_valid` on `valid_from, valid_to`
|
|
||||||
|
|
||||||
Mapper behavior:
|
|
||||||
|
|
||||||
- Extend `mapPostgresEvent` for `standards.SchemaWeatherOutlookV1`.
|
|
||||||
- Decode `model.WeatherOutlookRun`.
|
|
||||||
- Require run `AsOf`.
|
|
||||||
- Map envelope columns exactly like existing parent run tables.
|
|
||||||
- Store all times as UTC.
|
|
||||||
- Write one `outlook_runs` row and one `outlooks` row per outlook.
|
|
||||||
- Use `outlook_index` as the zero-based slice index.
|
|
||||||
- Require outlook `ID`, `Provider`, `Product`, `Day`, `OutlookType`, `Label`, `ValidFrom`, `ValidTo`, `IssuedAt`, `ExpiresAt`, and non-empty `Geometry`.
|
|
||||||
- Store compact geometry JSON text in `geometry_json` using the existing compact JSON helper or a similar helper.
|
|
||||||
- Preserve all outlook polygons, not only those containing the configured point.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Schema includes `outlook_runs` and `outlooks`.
|
|
||||||
- Schema includes required columns and indexes.
|
|
||||||
- Mapper writes one run row plus one row per outlook.
|
|
||||||
- Mapper stores UTC times.
|
|
||||||
- Mapper stores compact geometry JSON.
|
|
||||||
- Mapper preserves `contains_location` false as false, not nil.
|
|
||||||
- Mapper rejects missing run `asOf`.
|
|
||||||
- Mapper rejects missing required outlook times.
|
|
||||||
- Mapper rejects empty geometry.
|
|
||||||
- Mapper ignores unrelated schemas unchanged.
|
|
||||||
|
|
||||||
Verification:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/sinks/postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
Stage completion criteria:
|
|
||||||
|
|
||||||
- Postgres schema and mapper tests pass.
|
|
||||||
- No weatherapi read path is added in this stage.
|
|
||||||
|
|
||||||
## Stage 6: Config, Runtime Wiring, And Current-Behavior Docs
|
|
||||||
|
|
||||||
Goal: expose the completed source/normalizer/sink behavior in maintained configuration and documentation after implementation exists.
|
|
||||||
|
|
||||||
Files to update:
|
|
||||||
|
|
||||||
- `cmd/weatherfeeder/config.yml`
|
|
||||||
- `cmd/weatherfeeder/main_test.go`, if config load expectations need updates
|
|
||||||
- `docs/config.md`
|
|
||||||
- `docs/integrations/events.md`
|
|
||||||
- `docs/integrations/postgres.md`
|
|
||||||
- `docs/internal/sources.md`
|
|
||||||
- `docs/internal/normalizers.md`
|
|
||||||
- `docs/internal/postgres-sink.md`
|
|
||||||
- `README.md`
|
|
||||||
|
|
||||||
Config changes:
|
|
||||||
|
|
||||||
- Add sample source `SPCConvectiveOutlookSTL` with driver `spc_convective_outlook`, kind `outlook`, `every: 30m`, latitude, longitude, location metadata, and `user_agent`.
|
|
||||||
- Add `outlook` route examples for stdout, NATS, and Postgres where the existing config style includes kind lists.
|
|
||||||
- Keep optional RSS disabled in sample config unless there is a specific operator reason to include it.
|
|
||||||
|
|
||||||
Docs changes:
|
|
||||||
|
|
||||||
- Update current-behavior docs only after the code for that behavior exists.
|
|
||||||
- `docs/config.md` should document required and optional source params.
|
|
||||||
- `docs/integrations/events.md` should document `weather.outlook.v1`, including field definitions, required fields, optional fields, geometry semantics, and `containsLocation` semantics.
|
|
||||||
- `docs/integrations/postgres.md` should document `outlook_runs` and `outlooks`.
|
|
||||||
- Internal docs should explain provider boundaries, print-page discussion parsing, and point-in-polygon behavior where useful for maintainers.
|
|
||||||
- `README.md` should only briefly list SPC convective outlook support and link to canonical docs.
|
|
||||||
- Do not document weatherapi endpoints in weatherfeeder current-behavior docs.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Existing config load tests pass.
|
|
||||||
- Add config test coverage if the sample config is expected to build scheduler jobs for the new source.
|
|
||||||
- Documentation examples should use the real driver name and event kind.
|
|
||||||
|
|
||||||
Verification:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./cmd/weatherfeeder ./internal/sources ./internal/normalizers ./internal/sinks/postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
Stage completion criteria:
|
|
||||||
|
|
||||||
- Sample config remains loadable.
|
|
||||||
- Current-behavior docs match implemented code.
|
|
||||||
- No roadmap-only claims leak into non-roadmap docs beyond the implemented behavior.
|
|
||||||
|
|
||||||
## Stage 7: Full Verification And Cleanup
|
|
||||||
|
|
||||||
Goal: validate the complete feature and remove implementation-only rough edges.
|
|
||||||
|
|
||||||
Run focused tests:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/providers/spc ./internal/geo ./internal/sources ./internal/normalizers/... ./internal/sinks/postgres ./model ./cmd/weatherfeeder
|
|
||||||
```
|
|
||||||
|
|
||||||
Run full tests:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Manual review checklist:
|
|
||||||
|
|
||||||
- `standards/schema.go` contains raw and canonical SPC schema constants.
|
|
||||||
- `model/outlook.go` uses stable JSON tags and no provider-specific names except canonical strings.
|
|
||||||
- Source registry includes `spc_convective_outlook`.
|
|
||||||
- Normalizer registry includes the SPC normalizer.
|
|
||||||
- Source tests do not use live SPC services.
|
|
||||||
- Normalizer tests use fixtures and cover discussion parsing, geometry, and timestamp failures.
|
|
||||||
- Postgres tests cover schema shape and mapper validation.
|
|
||||||
- Docs follow `docs/policy/documentation.md` and use canonical homes.
|
|
||||||
- No weatherapi files are changed.
|
|
||||||
- No broad dependency was added without clear justification.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- A configured SPC source emits one raw bundled `outlook` event per changed complete product snapshot.
|
|
||||||
- Normalization produces `weather.outlook.v1` events.
|
|
||||||
- All Day 1-3 categorical, tornado, hail, and wind products are represented.
|
|
||||||
- Each outlook includes `containsLocation` for the configured latitude and longitude.
|
|
||||||
- Each outlook preserves compact GeoJSON geometry.
|
|
||||||
- Day 1-3 print-page discussion text is preserved in canonical headline, summary, and discussion fields where parseable.
|
|
||||||
- RSS metadata is optional and supplemental only.
|
|
||||||
- Postgres sink persists outlook runs and outlook rows.
|
|
||||||
- Sample config and current-behavior docs describe the implemented kind, driver, schema, and storage contract.
|
|
||||||
|
|
||||||
## Suggested Prompt Boundaries
|
|
||||||
|
|
||||||
This feature is too broad for a single safe implementation prompt. Use these implementation prompts in order:
|
|
||||||
|
|
||||||
1. Implement Stage 1 provider helpers and fixtures only.
|
|
||||||
2. Implement Stage 2 geometry helper only.
|
|
||||||
3. Implement Stage 3 source driver and raw schema only.
|
|
||||||
4. Implement Stage 4 canonical model and normalizer only.
|
|
||||||
5. Implement Stage 5 Postgres sink mapping only.
|
|
||||||
6. Implement Stage 6 config and current-behavior documentation only.
|
|
||||||
7. Run Stage 7 verification and perform targeted fixes only.
|
|
||||||
|
|
||||||
Each prompt should run its stage-specific tests before moving on. Do not proceed to the next stage with failing tests unless the failure is unrelated and explicitly documented.
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
# SPC Convective Outlook Support Roadmap
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Add `weatherfeeder` support for Storm Prediction Center convective outlooks as a new canonical outlook domain. The feature should poll SPC GeoJSON outlook products, enrich them with Day 1-3 print-page discussion text, compute whether the configured forecast point is inside each active outlook polygon, normalize the result into a provider-independent schema, and persist it through existing sinks.
|
|
||||||
|
|
||||||
This is a new domain, not an extension of `weather.alert.v1`. SPC outlooks describe probabilistic/categorical risk areas over a validity window; alerts describe active actionable hazard messages.
|
|
||||||
|
|
||||||
## Public Contract
|
|
||||||
|
|
||||||
Add schema constants:
|
|
||||||
|
|
||||||
- Raw schema: `raw.spc.convective_outlook.v1`
|
|
||||||
- Canonical schema: `weather.outlook.v1`
|
|
||||||
|
|
||||||
Add source driver:
|
|
||||||
|
|
||||||
- `spc_convective_outlook`
|
|
||||||
|
|
||||||
Add event kind:
|
|
||||||
|
|
||||||
- `outlook`
|
|
||||||
|
|
||||||
Add canonical model types:
|
|
||||||
|
|
||||||
- `model.WeatherOutlookRun`
|
|
||||||
- `model.WeatherOutlook`
|
|
||||||
|
|
||||||
Recommended canonical run fields:
|
|
||||||
|
|
||||||
- `locationId`, `locationName`
|
|
||||||
- `latitude`, `longitude`
|
|
||||||
- `asOf`
|
|
||||||
- `issuedAt`
|
|
||||||
- `outlooks`
|
|
||||||
|
|
||||||
Recommended canonical outlook fields:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `provider`
|
|
||||||
- `product`
|
|
||||||
- `day`
|
|
||||||
- `outlookType`
|
|
||||||
- `label`
|
|
||||||
- `labelText`
|
|
||||||
- `severityRank`
|
|
||||||
- `validFrom`
|
|
||||||
- `validTo`
|
|
||||||
- `issuedAt`
|
|
||||||
- `expiresAt`
|
|
||||||
- `forecaster`
|
|
||||||
- `headline`
|
|
||||||
- `summary`
|
|
||||||
- `discussion`
|
|
||||||
- `sourceUrl`
|
|
||||||
- `imageUrl`
|
|
||||||
- `containsLocation`
|
|
||||||
- `geometry`
|
|
||||||
|
|
||||||
Contract defaults:
|
|
||||||
|
|
||||||
- `product` should be `convective`.
|
|
||||||
- `outlookType` should be one of `categorical`, `tornado`, `hail`, `wind`.
|
|
||||||
- `day` should be `1`, `2`, or `3` for this first implementation.
|
|
||||||
- `containsLocation` is computed against configured forecast coordinates.
|
|
||||||
- `geometry` should preserve compact GeoJSON geometry for auditability and future API use.
|
|
||||||
- Day 4-8 outlooks are out of scope for this first implementation. They have different product semantics and should be added in a later schema-compatible extension only after Day 1-3 support is proven.
|
|
||||||
|
|
||||||
## Source Scope
|
|
||||||
|
|
||||||
The source should fetch a bundle of SPC products in one poll cycle and emit one raw event containing fetched GeoJSON products, fetched Day 1-3 print pages, optional RSS metadata, configured point metadata, and per-product fetch metadata. The source may decode minimal timestamps for event identity/effective time, but provider-to-canonical mapping belongs in the normalizer.
|
|
||||||
|
|
||||||
Poll these GeoJSON URLs:
|
|
||||||
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_cat.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_torn.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_hail.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_wind.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_cat.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_torn.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_hail.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_wind.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_cat.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_torn.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_hail.nolyr.geojson`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_wind.nolyr.geojson`
|
|
||||||
|
|
||||||
Implementation note: the initial candidate list duplicated Day 3 wind and omitted Day 2 wind. Use the corrected 12-product matrix above.
|
|
||||||
|
|
||||||
Poll these print-page discussion URLs:
|
|
||||||
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day1otlk_prt.html`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day2otlk_prt.html`
|
|
||||||
- `https://www.spc.noaa.gov/products/outlook/day3otlk_prt.html`
|
|
||||||
|
|
||||||
Print-page usage:
|
|
||||||
|
|
||||||
- Use print pages as the authoritative source for Day 1-3 outlook discussion text.
|
|
||||||
- Preserve source URL, page title/header where available, page-level `Updated:` timestamp where parseable, and the raw HTML or extracted `<pre>` product block in the raw bundle.
|
|
||||||
- Extract canonical `headline`, `summary`, `discussion`, and discussion metadata in the normalizer or shared SPC provider helper.
|
|
||||||
- Treat the GeoJSON files, not print pages, as authoritative for polygons and validity windows.
|
|
||||||
- Ignore page risk tables and image assets for v1 unless they are needed to support discussion parsing.
|
|
||||||
|
|
||||||
Optionally poll:
|
|
||||||
|
|
||||||
- `https://www.spc.noaa.gov/products/spcacrss.xml`
|
|
||||||
|
|
||||||
RSS usage:
|
|
||||||
|
|
||||||
- Use RSS as supplemental discovery or health metadata, not as the primary discussion or geometry source.
|
|
||||||
- Preserve item title, link, description text, pubDate, and guid where useful.
|
|
||||||
- Do not depend on RSS completeness for Day 1-3 discussion text. The RSS feed can omit current outlook products, including a current Day 2 discussion.
|
|
||||||
- Do not attempt to parse polygons from RSS HTML.
|
|
||||||
|
|
||||||
Polling cadence:
|
|
||||||
|
|
||||||
- Use a default cadence of `30m`, not daily/twice-daily. SPC current outlook files can update several times per day, and a 30-minute poll with HTTP caching is low cost and less likely to miss updates.
|
|
||||||
- Rely on ETag/Last-Modified handling from the HTTP source layer where available.
|
|
||||||
- Keep the cadence configurable via `every`.
|
|
||||||
|
|
||||||
Recommended config shape:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: SPCConvectiveOutlookSTL
|
|
||||||
mode: poll
|
|
||||||
kinds: ["outlook"]
|
|
||||||
driver: spc_convective_outlook
|
|
||||||
every: 30m
|
|
||||||
params:
|
|
||||||
latitude: 38.6239
|
|
||||||
longitude: -90.3571
|
|
||||||
location_id: "stl"
|
|
||||||
location_name: "St. Louis, MO"
|
|
||||||
user_agent: "HomeOps (eric@maximumdirect.net)"
|
|
||||||
```
|
|
||||||
|
|
||||||
The source should own default SPC URLs, with optional params to override GeoJSON URLs, print-page URLs, and the RSS URL for tests or future provider changes.
|
|
||||||
|
|
||||||
## Implementation Stages
|
|
||||||
|
|
||||||
### Stage 1: Raw Source and Schema
|
|
||||||
|
|
||||||
- Add schema constants.
|
|
||||||
- Add `internal/sources/spc` package.
|
|
||||||
- Implement `spc_convective_outlook` as a poll source.
|
|
||||||
- Fetch all configured GeoJSON products and Day 1-3 print pages during a poll cycle.
|
|
||||||
- Fetch RSS only when configured or retained as an optional supplemental source.
|
|
||||||
- Emit one raw event with kind `outlook` and schema `raw.spc.convective_outlook.v1`.
|
|
||||||
- Use the latest valid `ISSUE_ISO`, print-page `Updated:` timestamp, RSS `lastBuildDate`, or fetch time for `effectiveAt`, in that order.
|
|
||||||
- Preserve partial fetch errors as source errors for the first implementation; do not emit incomplete outlook bundles unless a later explicit degraded-mode policy is added.
|
|
||||||
- Keep source tests deterministic with local test servers and fixtures; do not call live SPC services from unit tests.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- source driver builds as a `PollSource`
|
|
||||||
- source advertises kind `outlook`
|
|
||||||
- source emits one raw bundled event
|
|
||||||
- source chooses expected effective time
|
|
||||||
- source uses configured location metadata
|
|
||||||
- source fails clearly on missing latitude/longitude
|
|
||||||
- source fails clearly when a configured GeoJSON or print-page fetch fails
|
|
||||||
|
|
||||||
### Stage 2: GeoJSON and Spatial Matching
|
|
||||||
|
|
||||||
- Add an internal geometry helper package, for example `internal/geo`.
|
|
||||||
- Support GeoJSON `Polygon` and `MultiPolygon`.
|
|
||||||
- Implement point-in-polygon with ring support:
|
|
||||||
- first ring is the exterior
|
|
||||||
- subsequent rings are holes
|
|
||||||
- boundary counts as inside
|
|
||||||
- GeoJSON coordinate order is `[longitude, latitude]`
|
|
||||||
- Use planar ray casting for this operational point-in-polygon check.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- point inside polygon
|
|
||||||
- point outside polygon
|
|
||||||
- point on polygon boundary
|
|
||||||
- point inside a hole is outside
|
|
||||||
- point inside one multipolygon member is inside
|
|
||||||
- longitude/latitude order regression test
|
|
||||||
|
|
||||||
### Stage 3: Normalizer and Canonical Mapping
|
|
||||||
|
|
||||||
- Add `model.WeatherOutlookRun` and `model.WeatherOutlook`.
|
|
||||||
- Add `SPCConvectiveOutlookNormalizer`.
|
|
||||||
- Match only `raw.spc.convective_outlook.v1`.
|
|
||||||
- Decode the raw source bundle.
|
|
||||||
- Map each GeoJSON feature to one `WeatherOutlook`.
|
|
||||||
- Preserve SPC feature order within each product, then order products by day and type.
|
|
||||||
- Map properties:
|
|
||||||
- `VALID_ISO` -> `validFrom`
|
|
||||||
- `EXPIRE_ISO` -> `validTo` and `expiresAt`
|
|
||||||
- `ISSUE_ISO` -> `issuedAt`
|
|
||||||
- `FORECASTER` -> `forecaster`
|
|
||||||
- `LABEL` -> `label`
|
|
||||||
- `LABEL2` -> `labelText`
|
|
||||||
- `DN` -> `severityRank`
|
|
||||||
- Derive:
|
|
||||||
- `day` from product key or URL
|
|
||||||
- `outlookType` from product key or URL
|
|
||||||
- `id` from day, type, label, issuedAt, validFrom, and feature index
|
|
||||||
- `containsLocation` from configured point and GeoJSON geometry
|
|
||||||
- Enrich narrative fields from the matching Day 1-3 print page:
|
|
||||||
- parse the main product text from the `<pre>` block
|
|
||||||
- strip embedded tags and scripts before text parsing
|
|
||||||
- preserve line breaks in discussion text
|
|
||||||
- parse page/title metadata, product title, issued line, valid line, correction markers such as `CORR 1`, forecaster, summary, and full discussion where practical
|
|
||||||
- use print-page text for canonical `headline`, `summary`, and `discussion`
|
|
||||||
- Use RSS only as optional supplemental metadata when present; missing or incomplete RSS must not prevent GeoJSON plus print-page normalization.
|
|
||||||
- Set run `asOf` to the latest valid `issuedAt` across features, falling back to print-page `Updated:` timestamps, RSS `lastBuildDate`, then input event time.
|
|
||||||
- Set run `issuedAt` to the latest valid `issuedAt` across features.
|
|
||||||
- Set output event `effectiveAt` to run `asOf`.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- normalizer routes only raw SPC schema
|
|
||||||
- categorical and probabilistic products map expected fields
|
|
||||||
- `containsLocation` is true for a known point inside a fixture polygon
|
|
||||||
- `containsLocation` is false outside
|
|
||||||
- print-page discussion text maps to canonical headline, summary, and discussion
|
|
||||||
- correction markers such as `CORR 1` are preserved where present
|
|
||||||
- missing optional RSS still permits GeoJSON and print-page normalization
|
|
||||||
- malformed required GeoJSON timestamps fail with useful context
|
|
||||||
- canonical JSON shape does not expose raw-provider-only bundle internals
|
|
||||||
|
|
||||||
### Stage 4: Postgres Sink
|
|
||||||
|
|
||||||
Add tables:
|
|
||||||
|
|
||||||
- `outlook_runs`
|
|
||||||
- `outlooks`
|
|
||||||
|
|
||||||
Suggested `outlook_runs` columns:
|
|
||||||
|
|
||||||
- event envelope columns
|
|
||||||
- `location_id`
|
|
||||||
- `location_name`
|
|
||||||
- `latitude`
|
|
||||||
- `longitude`
|
|
||||||
- `as_of`
|
|
||||||
- `issued_at`
|
|
||||||
- `outlook_count`
|
|
||||||
|
|
||||||
Suggested `outlooks` columns:
|
|
||||||
|
|
||||||
- `run_event_id`
|
|
||||||
- `outlook_index`
|
|
||||||
- `as_of`
|
|
||||||
- `product`
|
|
||||||
- `day`
|
|
||||||
- `outlook_type`
|
|
||||||
- `label`
|
|
||||||
- `label_text`
|
|
||||||
- `severity_rank`
|
|
||||||
- `valid_from`
|
|
||||||
- `valid_to`
|
|
||||||
- `issued_at`
|
|
||||||
- `expires_at`
|
|
||||||
- `forecaster`
|
|
||||||
- `headline`
|
|
||||||
- `summary`
|
|
||||||
- `discussion`
|
|
||||||
- `source_url`
|
|
||||||
- `image_url`
|
|
||||||
- `contains_location`
|
|
||||||
- `geometry_json`
|
|
||||||
|
|
||||||
Suggested indexes:
|
|
||||||
|
|
||||||
- `outlook_runs(location_id, as_of)`
|
|
||||||
- `outlooks(contains_location, valid_from, valid_to)`
|
|
||||||
- `outlooks(day, outlook_type, label)`
|
|
||||||
- `outlooks(valid_from, valid_to)`
|
|
||||||
|
|
||||||
Mapping rules:
|
|
||||||
|
|
||||||
- Store `geometry` as compact GeoJSON text in `geometry_json`.
|
|
||||||
- Require run `asOf`.
|
|
||||||
- Require outlook `validFrom`, `validTo`, `issuedAt`, `day`, `outlookType`, and `label`.
|
|
||||||
- Preserve all outlook polygons, not only polygons containing the configured point.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- schema includes outlook tables and indexes
|
|
||||||
- mapper writes one run row plus one row per outlook
|
|
||||||
- mapper stores compact geometry JSON
|
|
||||||
- mapper rejects missing required run/outlook fields
|
|
||||||
|
|
||||||
### Stage 5: Config and Documentation
|
|
||||||
|
|
||||||
- Update sample config with `SPCConvectiveOutlookSTL`.
|
|
||||||
- Add `outlook` to route examples.
|
|
||||||
- Update current-behavior docs in the implementation change, not before behavior exists.
|
|
||||||
- Update `docs/config.md` with source params.
|
|
||||||
- Update `docs/integrations/events.md` with `weather.outlook.v1`.
|
|
||||||
- Update `docs/integrations/postgres.md` with outlook table contract.
|
|
||||||
- Update relevant internal docs under `docs/internal/` if source, normalizer, or sink behavior needs maintainer context.
|
|
||||||
- Update `README.md` only with brief implemented-provider capability and links to canonical docs.
|
|
||||||
|
|
||||||
Docs should clearly state:
|
|
||||||
|
|
||||||
- Print pages are used for Day 1-3 narrative/discussion text.
|
|
||||||
- RSS, if retained, is supplemental and not required for complete discussion coverage.
|
|
||||||
- GeoJSON is used for polygons, validity windows, and point matching.
|
|
||||||
- `containsLocation` is computed by weatherfeeder at ingestion time.
|
|
||||||
- Geometry is stored for downstream audit/display.
|
|
||||||
- Day 4-8 outlooks are not included in the initial implementation.
|
|
||||||
|
|
||||||
### Stage 6: Weatherapi Follow-Up
|
|
||||||
|
|
||||||
Do not include weatherapi changes in the first weatherfeeder implementation unless explicitly requested.
|
|
||||||
|
|
||||||
Likely future weatherapi endpoints:
|
|
||||||
|
|
||||||
- `GET /outlooks/convective`
|
|
||||||
- `GET /outlooks/convective/active`
|
|
||||||
- `GET /outlooks/convective/location`
|
|
||||||
|
|
||||||
Recommended API behavior:
|
|
||||||
|
|
||||||
- latest run by default
|
|
||||||
- active outlooks filtered by current time and `containsLocation=true`
|
|
||||||
- optional filter query params for `day`, `outlookType`, and `containsLocation`
|
|
||||||
|
|
||||||
## Verification Commands
|
|
||||||
|
|
||||||
Run focused tests:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/sources ./internal/normalizers/... ./internal/sinks/postgres ./model
|
|
||||||
```
|
|
||||||
|
|
||||||
Run full weatherfeeder tests:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- A configured SPC source emits raw outlook bundles.
|
|
||||||
- Normalization produces `weather.outlook.v1` events.
|
|
||||||
- Each outlook indicates whether the configured forecast point is inside its polygon.
|
|
||||||
- All current Day 1-3 categorical/tornado/hail/wind products are represented.
|
|
||||||
- Day 1-3 print-page discussion text is preserved where available.
|
|
||||||
- RSS metadata is optional and supplemental if retained.
|
|
||||||
- Postgres sink persists outlook runs and outlook rows.
|
|
||||||
- Sample config and public docs describe the new kind, driver, schema, and storage contract.
|
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFinalizeRoundsWeatherPayloadFloats(t *testing.T) {
|
func TestFinalizeRoundsWeatherPayloadFloats(t *testing.T) {
|
||||||
@@ -14,7 +15,7 @@ func TestFinalizeRoundsWeatherPayloadFloats(t *testing.T) {
|
|||||||
|
|
||||||
in := event.Event{
|
in := event.Event{
|
||||||
ID: "evt-1",
|
ID: "evt-1",
|
||||||
Kind: event.Kind("observation"),
|
Kind: event.Kind(standards.KindObservation),
|
||||||
Source: "source-a",
|
Source: "source-a",
|
||||||
EmittedAt: time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC),
|
EmittedAt: time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC),
|
||||||
Schema: "raw.example.v1",
|
Schema: "raw.example.v1",
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ import (
|
|||||||
// Errors include a small amount of operation context ("extract payload", "decode raw payload").
|
// Errors include a small amount of operation context ("extract payload", "decode raw payload").
|
||||||
// Callers typically wrap these with a provider/kind label.
|
// Callers typically wrap these with a provider/kind label.
|
||||||
func DecodeJSONPayload[T any](in event.Event) (T, error) {
|
func DecodeJSONPayload[T any](in event.Event) (T, error) {
|
||||||
|
var zero T
|
||||||
|
|
||||||
|
if typed, ok := in.Payload.(T); ok {
|
||||||
|
return typed, nil
|
||||||
|
}
|
||||||
|
if ptr, ok := in.Payload.(*T); ok {
|
||||||
|
if ptr == nil {
|
||||||
|
return zero, fmt.Errorf("extract payload: payload pointer is nil")
|
||||||
|
}
|
||||||
|
return *ptr, nil
|
||||||
|
}
|
||||||
|
|
||||||
return fknormalize.DecodeJSONPayload[T](in)
|
return fknormalize.DecodeJSONPayload[T](in)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
internal/normalizers/common/json_test.go
Normal file
39
internal/normalizers/common/json_test.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecodeJSONPayloadAcceptsTypedPayload(t *testing.T) {
|
||||||
|
type rawPayload struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := DecodeJSONPayload[rawPayload](event.Event{
|
||||||
|
Payload: rawPayload{Value: "ok"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeJSONPayload() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.Value != "ok" {
|
||||||
|
t.Fatalf("Value = %q, want ok", got.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeJSONPayloadAcceptsTypedPointerPayload(t *testing.T) {
|
||||||
|
type rawPayload struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := DecodeJSONPayload[rawPayload](event.Event{
|
||||||
|
Payload: &rawPayload{Value: "ok"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeJSONPayload() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.Value != "ok" {
|
||||||
|
t.Fatalf("Value = %q, want ok", got.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ func TestForecastDiscussionNormalizerProducesCanonicalSchema(t *testing.T) {
|
|||||||
|
|
||||||
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
||||||
ID: "evt-discussion-1",
|
ID: "evt-discussion-1",
|
||||||
Kind: event.Kind("forecast_discussion"),
|
Kind: event.Kind(standards.KindForecastDiscussion),
|
||||||
Source: "nws-discussion-test",
|
Source: "nws-discussion-test",
|
||||||
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
||||||
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
||||||
@@ -33,7 +33,7 @@ func TestForecastDiscussionNormalizerProducesCanonicalSchema(t *testing.T) {
|
|||||||
if out.Schema != standards.SchemaWeatherForecastDiscussionV1 {
|
if out.Schema != standards.SchemaWeatherForecastDiscussionV1 {
|
||||||
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1)
|
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1)
|
||||||
}
|
}
|
||||||
if out.Kind != event.Kind("forecast_discussion") {
|
if out.Kind != event.Kind(standards.KindForecastDiscussion) {
|
||||||
t.Fatalf("Kind = %q, want forecast_discussion", out.Kind)
|
t.Fatalf("Kind = %q, want forecast_discussion", out.Kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ func TestForecastDiscussionNormalizerProducesCanonicalSchema(t *testing.T) {
|
|||||||
func TestForecastDiscussionNormalizerRejectsMissingIssueTime(t *testing.T) {
|
func TestForecastDiscussionNormalizerRejectsMissingIssueTime(t *testing.T) {
|
||||||
_, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
_, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
||||||
ID: "evt-discussion-bad",
|
ID: "evt-discussion-bad",
|
||||||
Kind: event.Kind("forecast_discussion"),
|
Kind: event.Kind(standards.KindForecastDiscussion),
|
||||||
Source: "nws-discussion-test",
|
Source: "nws-discussion-test",
|
||||||
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
||||||
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
||||||
@@ -93,7 +93,7 @@ func TestForecastDiscussionNormalizerWireShapeHasNoUnexpectedKeys(t *testing.T)
|
|||||||
|
|
||||||
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
||||||
ID: "evt-discussion-2",
|
ID: "evt-discussion-2",
|
||||||
Kind: event.Kind("forecast_discussion"),
|
Kind: event.Kind(standards.KindForecastDiscussion),
|
||||||
Source: "nws-discussion-test",
|
Source: "nws-discussion-test",
|
||||||
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
||||||
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ func TestNormalizeForecastEventBySchemaProducesCanonicalWeatherForecastSchema(t
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
out, err := normalizeForecastEventBySchema(event.Event{
|
out, err := normalizeForecastEventBySchema(event.Event{
|
||||||
ID: "evt-1",
|
ID: "evt-1",
|
||||||
Kind: event.Kind("forecast"),
|
Kind: event.Kind(standards.KindForecast),
|
||||||
Source: "nws-test",
|
Source: "nws-test",
|
||||||
EmittedAt: time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC),
|
EmittedAt: time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC),
|
||||||
Schema: tt.schema,
|
Schema: tt.schema,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func TestWeatherStoriesNormalizerProducesCanonicalSchemaAndMapsSample(t *testing
|
|||||||
if out.Schema != standards.SchemaWeatherStoryV1 {
|
if out.Schema != standards.SchemaWeatherStoryV1 {
|
||||||
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherStoryV1)
|
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherStoryV1)
|
||||||
}
|
}
|
||||||
if out.Kind != event.Kind("weather_story") {
|
if out.Kind != event.Kind(standards.KindWeatherStory) {
|
||||||
t.Fatalf("Kind = %q, want weather_story", out.Kind)
|
t.Fatalf("Kind = %q, want weather_story", out.Kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ func TestWeatherStoriesNormalizerMatch(t *testing.T) {
|
|||||||
func weatherStoriesRawEvent(payload string) event.Event {
|
func weatherStoriesRawEvent(payload string) event.Event {
|
||||||
return event.Event{
|
return event.Event{
|
||||||
ID: "evt-weatherstories-1",
|
ID: "evt-weatherstories-1",
|
||||||
Kind: event.Kind("weather_story"),
|
Kind: event.Kind(standards.KindWeatherStory),
|
||||||
Source: "nws-weatherstories-test",
|
Source: "nws-weatherstories-test",
|
||||||
EmittedAt: time.Date(2026, 5, 30, 9, 5, 0, 0, time.UTC),
|
EmittedAt: time.Date(2026, 5, 30, 9, 5, 0, 0, time.UTC),
|
||||||
Schema: standards.SchemaRawNWSWeatherStoriesV1,
|
Schema: standards.SchemaRawNWSWeatherStoriesV1,
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ const (
|
|||||||
providerSPC = "spc"
|
providerSPC = "spc"
|
||||||
productConvective = "convective"
|
productConvective = "convective"
|
||||||
outlookNormalizer = "spc convective outlook"
|
outlookNormalizer = "spc convective outlook"
|
||||||
outlookKind = "outlook"
|
|
||||||
outlookTypeUnknown = 99
|
outlookTypeUnknown = 99
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,11 +137,10 @@ func parseDiscussions(pages []spcprovider.RawDiscussionPage) (map[int]parsedDisc
|
|||||||
out := map[int]parsedDiscussion{}
|
out := map[int]parsedDiscussion{}
|
||||||
var latestUpdated time.Time
|
var latestUpdated time.Time
|
||||||
for _, page := range pages {
|
for _, page := range pages {
|
||||||
text, err := spcprovider.ExtractProductText(page.Body)
|
parsed, err := spcprovider.ParseDiscussionHTML(page.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, time.Time{}, fmt.Errorf("discussion %s: %w", page.Key, err)
|
return nil, time.Time{}, fmt.Errorf("discussion %s: %w", page.Key, err)
|
||||||
}
|
}
|
||||||
parsed := spcprovider.ParseDiscussionText(text)
|
|
||||||
day := page.Day
|
day := page.Day
|
||||||
if day == 0 {
|
if day == 0 {
|
||||||
if meta, ok := spcprovider.DiscussionProductByKey(page.Key); ok {
|
if meta, ok := spcprovider.DiscussionProductByKey(page.Key); ok {
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package spc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -32,7 +30,7 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test
|
|||||||
if out.Schema != standards.SchemaWeatherOutlookV1 {
|
if out.Schema != standards.SchemaWeatherOutlookV1 {
|
||||||
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherOutlookV1)
|
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherOutlookV1)
|
||||||
}
|
}
|
||||||
if out.Kind != event.Kind("outlook") {
|
if out.Kind != event.Kind(standards.KindOutlook) {
|
||||||
t.Fatalf("Kind = %q, want outlook", out.Kind)
|
t.Fatalf("Kind = %q, want outlook", out.Kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +54,8 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test
|
|||||||
if run.Latitude == nil || *run.Latitude != 38.5 || run.Longitude == nil || *run.Longitude != -90.5 {
|
if run.Latitude == nil || *run.Latitude != 38.5 || run.Longitude == nil || *run.Longitude != -90.5 {
|
||||||
t.Fatalf("coordinates = %v,%v", run.Latitude, run.Longitude)
|
t.Fatalf("coordinates = %v,%v", run.Latitude, run.Longitude)
|
||||||
}
|
}
|
||||||
if len(run.Outlooks) != 12 {
|
if len(run.Outlooks) != 9 {
|
||||||
t.Fatalf("Outlooks length = %d, want 12", len(run.Outlooks))
|
t.Fatalf("Outlooks length = %d, want 9", len(run.Outlooks))
|
||||||
}
|
}
|
||||||
|
|
||||||
got := run.Outlooks[0]
|
got := run.Outlooks[0]
|
||||||
@@ -99,11 +97,29 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test
|
|||||||
if !strings.Contains(got.Discussion, "...DISCUSSION...") {
|
if !strings.Contains(got.Discussion, "...DISCUSSION...") {
|
||||||
t.Fatalf("Discussion missing product text: %q", got.Discussion)
|
t.Fatalf("Discussion missing product text: %q", got.Discussion)
|
||||||
}
|
}
|
||||||
|
if !strings.HasPrefix(got.Discussion, "SPC AC 111234") {
|
||||||
|
t.Fatalf("Discussion = %q, want SPC product code prefix", got.Discussion)
|
||||||
|
}
|
||||||
if got.ID != "spc-convective-day1-categorical-slgt-2026-06-11T12:34:56Z-2026-06-11T13:00:00Z-0" {
|
if got.ID != "spc-convective-day1-categorical-slgt-2026-06-11T12:34:56Z-2026-06-11T13:00:00Z-0" {
|
||||||
t.Fatalf("ID = %q", got.ID)
|
t.Fatalf("ID = %q", got.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvectiveOutlookNormalizerAcceptsTypedSourcePayload(t *testing.T) {
|
||||||
|
bundle := spcBundle(t, 38.5, -90.5)
|
||||||
|
in := spcRawEvent(t, bundle)
|
||||||
|
in.Payload = bundle
|
||||||
|
|
||||||
|
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
run := out.Payload.(model.WeatherOutlookRun)
|
||||||
|
if len(run.Outlooks) != 9 {
|
||||||
|
t.Fatalf("Outlooks length = %d, want 9", len(run.Outlooks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConvectiveOutlookNormalizerOrdersProductsByDayAndType(t *testing.T) {
|
func TestConvectiveOutlookNormalizerOrdersProductsByDayAndType(t *testing.T) {
|
||||||
bundle := spcBundle(t, 0, 0)
|
bundle := spcBundle(t, 0, 0)
|
||||||
for i, j := 0, len(bundle.Products)-1; i < j; i, j = i+1, j-1 {
|
for i, j := 0, len(bundle.Products)-1; i < j; i, j = i+1, j-1 {
|
||||||
@@ -273,7 +289,7 @@ func spcRawEvent(t *testing.T, bundle spcprovider.RawConvectiveOutlookBundle) ev
|
|||||||
effectiveAt := time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC)
|
effectiveAt := time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC)
|
||||||
return event.Event{
|
return event.Event{
|
||||||
ID: "evt-spc-outlook-1",
|
ID: "evt-spc-outlook-1",
|
||||||
Kind: event.Kind("outlook"),
|
Kind: event.Kind(standards.KindOutlook),
|
||||||
Source: "spc-test",
|
Source: "spc-test",
|
||||||
EmittedAt: time.Date(2026, 6, 11, 20, 5, 0, 0, time.UTC),
|
EmittedAt: time.Date(2026, 6, 11, 20, 5, 0, 0, time.UTC),
|
||||||
EffectiveAt: &effectiveAt,
|
EffectiveAt: &effectiveAt,
|
||||||
@@ -319,23 +335,13 @@ func geoJSONFixtureForProduct(t *testing.T, key string) []byte {
|
|||||||
case strings.HasPrefix(key, "day2_"):
|
case strings.HasPrefix(key, "day2_"):
|
||||||
return readSPCTestFixture(t, "day2_torn.geojson")
|
return readSPCTestFixture(t, "day2_torn.geojson")
|
||||||
case strings.HasPrefix(key, "day3_"):
|
case strings.HasPrefix(key, "day3_"):
|
||||||
return readSPCTestFixture(t, "day3_wind.geojson")
|
return readSPCTestFixture(t, "day3_cat.geojson")
|
||||||
default:
|
default:
|
||||||
t.Fatalf("unknown product key %q", key)
|
t.Fatalf("unknown product key %q", key)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readSPCTestFixture(t *testing.T, name string) []byte {
|
|
||||||
t.Helper()
|
|
||||||
path := filepath.Join("..", "..", "providers", "spc", "testdata", name)
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read fixture %s: %v", path, err)
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
|
|
||||||
func findOutlook(outlooks []model.WeatherOutlook, day int, outlookType string) *model.WeatherOutlook {
|
func findOutlook(outlooks []model.WeatherOutlook, day int, outlookType string) *model.WeatherOutlook {
|
||||||
for i := range outlooks {
|
for i := range outlooks {
|
||||||
if outlooks[i].Day == day && outlooks[i].OutlookType == outlookType {
|
if outlooks[i].Day == day && outlooks[i].OutlookType == outlookType {
|
||||||
|
|||||||
17
internal/normalizers/spc/fixture_test.go
Normal file
17
internal/normalizers/spc/fixture_test.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package spc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readSPCTestFixture(t *testing.T, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join("..", "..", "providers", "spc", "testdata", name)
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ var (
|
|||||||
preBlockRE = regexp.MustCompile(`(?is)<pre\b[^>]*>(.*?)</pre>`)
|
preBlockRE = regexp.MustCompile(`(?is)<pre\b[^>]*>(.*?)</pre>`)
|
||||||
tagRE = regexp.MustCompile(`(?is)<[^>]+>`)
|
tagRE = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||||
updatedRE = regexp.MustCompile(`(?im)^\s*Updated:\s*(.+?)\s*$`)
|
updatedRE = regexp.MustCompile(`(?im)^\s*Updated:\s*(.+?)\s*$`)
|
||||||
|
pageUpdatedRE = regexp.MustCompile(`(?i)\bUpdated:\s*((?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)|(?:[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+UTC\s+\d{4})|(?:\d{4}\s+UTC\s+[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{4})|(?:\d{4}Z\s+[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{4}))`)
|
||||||
|
productCodeRE = regexp.MustCompile(`(?i)^SPC\s+AC\s+\d+\s*$`)
|
||||||
sectionRE = regexp.MustCompile(`^\s*\.\.\.[A-Z0-9 /-]+\.{3}\s*$`)
|
sectionRE = regexp.MustCompile(`^\s*\.\.\.[A-Z0-9 /-]+\.{3}\s*$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,6 +43,20 @@ func ExtractProductText(rawHTML string) (string, error) {
|
|||||||
return "", fmt.Errorf("no useful pre block found")
|
return "", fmt.Errorf("no useful pre block found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseDiscussionHTML extracts SPC product text and page-level metadata from a
|
||||||
|
// print-page HTML document.
|
||||||
|
func ParseDiscussionHTML(rawHTML string) (DiscussionText, error) {
|
||||||
|
text, err := ExtractProductText(rawHTML)
|
||||||
|
if err != nil {
|
||||||
|
return DiscussionText{}, err
|
||||||
|
}
|
||||||
|
parsed := ParseDiscussionText(text)
|
||||||
|
if updatedAt := ParsePageUpdatedTimestamp(rawHTML); updatedAt != nil {
|
||||||
|
parsed.UpdatedAt = updatedAt
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ParseDiscussionText extracts common SPC narrative metadata from cleaned
|
// ParseDiscussionText extracts common SPC narrative metadata from cleaned
|
||||||
// product text.
|
// product text.
|
||||||
func ParseDiscussionText(text string) DiscussionText {
|
func ParseDiscussionText(text string) DiscussionText {
|
||||||
@@ -55,6 +71,19 @@ func ParseDiscussionText(text string) DiscussionText {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParsePageUpdatedTimestamp parses the page-level Updated row from an SPC print
|
||||||
|
// page. SPC currently places this outside the product <pre> block.
|
||||||
|
func ParsePageUpdatedTimestamp(rawHTML string) *time.Time {
|
||||||
|
text := cleanHTMLText(rawHTML)
|
||||||
|
text = strings.ReplaceAll(text, "\u00a0", " ")
|
||||||
|
text = strings.Join(strings.Fields(text), " ")
|
||||||
|
match := pageUpdatedRE.FindStringSubmatch(text)
|
||||||
|
if len(match) != 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return parseUpdatedValue(match[1])
|
||||||
|
}
|
||||||
|
|
||||||
// ParseUpdatedTimestamp parses an SPC print-page Updated line when present.
|
// ParseUpdatedTimestamp parses an SPC print-page Updated line when present.
|
||||||
func ParseUpdatedTimestamp(text string) *time.Time {
|
func ParseUpdatedTimestamp(text string) *time.Time {
|
||||||
match := updatedRE.FindStringSubmatch(normalizeNewlines(text))
|
match := updatedRE.FindStringSubmatch(normalizeNewlines(text))
|
||||||
@@ -68,7 +97,7 @@ func ParseUpdatedTimestamp(text string) *time.Time {
|
|||||||
func ParseProductTitle(text string) string {
|
func ParseProductTitle(text string) string {
|
||||||
for _, line := range strings.Split(normalizeNewlines(text), "\n") {
|
for _, line := range strings.Split(normalizeNewlines(text), "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if line == "" || strings.HasPrefix(line, "Updated:") {
|
if line == "" || strings.HasPrefix(line, "Updated:") || productCodeRE.MatchString(line) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return line
|
return line
|
||||||
@@ -142,6 +171,7 @@ func parseUpdatedValue(value string) *time.Time {
|
|||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
for _, layout := range []string{
|
for _, layout := range []string{
|
||||||
|
"Mon Jan 2 15:04:05 UTC 2006",
|
||||||
"1504 UTC Mon Jan 2 2006",
|
"1504 UTC Mon Jan 2 2006",
|
||||||
"1504Z Mon Jan 2 2006",
|
"1504Z Mon Jan 2 2006",
|
||||||
"3:04 PM UTC Mon Jan 2 2006",
|
"3:04 PM UTC Mon Jan 2 2006",
|
||||||
|
|||||||
@@ -22,18 +22,19 @@ func TestExtractProductTextCleansPreBlock(t *testing.T) {
|
|||||||
if !strings.Contains(got, "Day 1 Convective Outlook") {
|
if !strings.Contains(got, "Day 1 Convective Outlook") {
|
||||||
t.Fatalf("ExtractProductText() missing headline: %q", got)
|
t.Fatalf("ExtractProductText() missing headline: %q", got)
|
||||||
}
|
}
|
||||||
|
if !strings.HasPrefix(got, "SPC AC 111234") {
|
||||||
|
t.Fatalf("ExtractProductText() = %q, want product code prefix", got)
|
||||||
|
}
|
||||||
if strings.HasPrefix(got, "\n") || strings.HasSuffix(got, "\n") {
|
if strings.HasPrefix(got, "\n") || strings.HasSuffix(got, "\n") {
|
||||||
t.Fatalf("ExtractProductText() retained surrounding blank lines: %q", got)
|
t.Fatalf("ExtractProductText() retained surrounding blank lines: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseDiscussionTextExtractsHeadlineSummaryAndUpdated(t *testing.T) {
|
func TestParseDiscussionHTMLExtractsHeadlineSummaryAndUpdated(t *testing.T) {
|
||||||
text, err := ExtractProductText(string(readTestFile(t, "day1_prt.html")))
|
got, err := ParseDiscussionHTML(string(readTestFile(t, "day1_prt.html")))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ExtractProductText() error = %v", err)
|
t.Fatalf("ParseDiscussionHTML() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := ParseDiscussionText(text)
|
|
||||||
if got.ProductTitle != "Day 1 Convective Outlook" {
|
if got.ProductTitle != "Day 1 Convective Outlook" {
|
||||||
t.Fatalf("ProductTitle = %q", got.ProductTitle)
|
t.Fatalf("ProductTitle = %q", got.ProductTitle)
|
||||||
}
|
}
|
||||||
@@ -47,27 +48,37 @@ func TestParseDiscussionTextExtractsHeadlineSummaryAndUpdated(t *testing.T) {
|
|||||||
if !strings.Contains(got.Discussion, "...DISCUSSION...") {
|
if !strings.Contains(got.Discussion, "...DISCUSSION...") {
|
||||||
t.Fatalf("Discussion missing full text: %q", got.Discussion)
|
t.Fatalf("Discussion missing full text: %q", got.Discussion)
|
||||||
}
|
}
|
||||||
|
if !strings.HasPrefix(got.Discussion, "SPC AC 111234") {
|
||||||
|
t.Fatalf("Discussion = %q, want product code prefix", got.Discussion)
|
||||||
|
}
|
||||||
wantUpdated := time.Date(2026, 6, 11, 12, 45, 0, 0, time.UTC)
|
wantUpdated := time.Date(2026, 6, 11, 12, 45, 0, 0, time.UTC)
|
||||||
if got.UpdatedAt == nil || !got.UpdatedAt.Equal(wantUpdated) {
|
if got.UpdatedAt == nil || !got.UpdatedAt.Equal(wantUpdated) {
|
||||||
t.Fatalf("UpdatedAt = %v, want %s", got.UpdatedAt, wantUpdated)
|
t.Fatalf("UpdatedAt = %v, want %s", got.UpdatedAt, wantUpdated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseDiscussionTextPreservesCorrectionMarker(t *testing.T) {
|
func TestParseProductTitleSkipsSPCProductCode(t *testing.T) {
|
||||||
text, err := ExtractProductText(string(readTestFile(t, "day2_prt_corr.html")))
|
got := ParseProductTitle("SPC AC 101959\nDay 1 Convective Outlook\nNWS Storm Prediction Center Norman OK")
|
||||||
if err != nil {
|
if got != "Day 1 Convective Outlook" {
|
||||||
t.Fatalf("ExtractProductText() error = %v", err)
|
t.Fatalf("ParseProductTitle() = %q, want Day 1 Convective Outlook", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDiscussionTextPreservesCorrectionMarker(t *testing.T) {
|
||||||
|
got, err := ParseDiscussionHTML(string(readTestFile(t, "day2_prt_corr.html")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseDiscussionHTML() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := ParseDiscussionText(text)
|
|
||||||
if !strings.Contains(got.Headline, "CORR 1") {
|
if !strings.Contains(got.Headline, "CORR 1") {
|
||||||
t.Fatalf("Headline = %q, want correction marker", got.Headline)
|
t.Fatalf("Headline = %q, want correction marker", got.Headline)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got.Discussion, "CORR 1") {
|
if !strings.Contains(got.Discussion, "CORR 1") {
|
||||||
t.Fatalf("Discussion = %q, want correction marker", got.Discussion)
|
t.Fatalf("Discussion = %q, want correction marker", got.Discussion)
|
||||||
}
|
}
|
||||||
if got.UpdatedAt != nil {
|
wantUpdated := time.Date(2026, 6, 11, 17, 30, 0, 0, time.UTC)
|
||||||
t.Fatalf("UpdatedAt = %v, want nil", got.UpdatedAt)
|
if got.UpdatedAt == nil || !got.UpdatedAt.Equal(wantUpdated) {
|
||||||
|
t.Fatalf("UpdatedAt = %v, want %s", got.UpdatedAt, wantUpdated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,3 +99,11 @@ func TestParseUpdatedTimestampAcceptsSPCUTCFormat(t *testing.T) {
|
|||||||
t.Fatalf("ParseUpdatedTimestamp() = %v, want %s", got, want)
|
t.Fatalf("ParseUpdatedTimestamp() = %v, want %s", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParsePageUpdatedTimestampAcceptsLiveSPCShape(t *testing.T) {
|
||||||
|
got := ParsePageUpdatedTimestamp(string(readTestFile(t, "day3_prt.html")))
|
||||||
|
want := time.Date(2026, 6, 11, 20, 0, 0, 0, time.UTC)
|
||||||
|
if got == nil || !got.Equal(want) {
|
||||||
|
t.Fatalf("ParsePageUpdatedTimestamp() = %v, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
17
internal/providers/spc/fixture_test.go
Normal file
17
internal/providers/spc/fixture_test.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package spc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readTestFile(t *testing.T, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join("testdata", name)
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package spc
|
package spc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -78,13 +76,3 @@ func TestParseISOTimestampTrimsAndReturnsUTC(t *testing.T) {
|
|||||||
t.Fatalf("ParseISOTimestamp() = %s, want %s", got, want)
|
t.Fatalf("ParseISOTimestamp() = %s, want %s", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readTestFile(t *testing.T, name string) []byte {
|
|
||||||
t.Helper()
|
|
||||||
path := filepath.Join("testdata", name)
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read %s: %v", path, err)
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ var geoJSONProducts = []GeoJSONProduct{
|
|||||||
{Key: "day2_hail", Day: 2, OutlookType: OutlookTypeHail, URL: "https://www.spc.noaa.gov/products/outlook/day2otlk_hail.nolyr.geojson"},
|
{Key: "day2_hail", Day: 2, OutlookType: OutlookTypeHail, URL: "https://www.spc.noaa.gov/products/outlook/day2otlk_hail.nolyr.geojson"},
|
||||||
{Key: "day2_wind", Day: 2, OutlookType: OutlookTypeWind, URL: "https://www.spc.noaa.gov/products/outlook/day2otlk_wind.nolyr.geojson"},
|
{Key: "day2_wind", Day: 2, OutlookType: OutlookTypeWind, URL: "https://www.spc.noaa.gov/products/outlook/day2otlk_wind.nolyr.geojson"},
|
||||||
{Key: "day3_categorical", Day: 3, OutlookType: OutlookTypeCategorical, URL: "https://www.spc.noaa.gov/products/outlook/day3otlk_cat.nolyr.geojson"},
|
{Key: "day3_categorical", Day: 3, OutlookType: OutlookTypeCategorical, URL: "https://www.spc.noaa.gov/products/outlook/day3otlk_cat.nolyr.geojson"},
|
||||||
{Key: "day3_tornado", Day: 3, OutlookType: OutlookTypeTornado, URL: "https://www.spc.noaa.gov/products/outlook/day3otlk_torn.nolyr.geojson"},
|
|
||||||
{Key: "day3_hail", Day: 3, OutlookType: OutlookTypeHail, URL: "https://www.spc.noaa.gov/products/outlook/day3otlk_hail.nolyr.geojson"},
|
|
||||||
{Key: "day3_wind", Day: 3, OutlookType: OutlookTypeWind, URL: "https://www.spc.noaa.gov/products/outlook/day3otlk_wind.nolyr.geojson"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var discussionProducts = []DiscussionProduct{
|
var discussionProducts = []DiscussionProduct{
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import "testing"
|
|||||||
|
|
||||||
func TestGeoJSONProductsStableOrder(t *testing.T) {
|
func TestGeoJSONProductsStableOrder(t *testing.T) {
|
||||||
got := GeoJSONProducts()
|
got := GeoJSONProducts()
|
||||||
if len(got) != 12 {
|
if len(got) != 9 {
|
||||||
t.Fatalf("GeoJSONProducts() length = %d, want 12", len(got))
|
t.Fatalf("GeoJSONProducts() length = %d, want 9", len(got))
|
||||||
}
|
}
|
||||||
|
|
||||||
wantKeys := []string{
|
wantKeys := []string{
|
||||||
@@ -18,9 +18,6 @@ func TestGeoJSONProductsStableOrder(t *testing.T) {
|
|||||||
"day2_hail",
|
"day2_hail",
|
||||||
"day2_wind",
|
"day2_wind",
|
||||||
"day3_categorical",
|
"day3_categorical",
|
||||||
"day3_tornado",
|
|
||||||
"day3_hail",
|
|
||||||
"day3_wind",
|
|
||||||
}
|
}
|
||||||
for i, want := range wantKeys {
|
for i, want := range wantKeys {
|
||||||
if got[i].Key != want {
|
if got[i].Key != want {
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
<html>
|
<html>
|
||||||
<head><title>Day 1 Convective Outlook</title></head>
|
<head><title>Day 1 Convective Outlook</title></head>
|
||||||
<body>
|
<body>
|
||||||
|
<table>
|
||||||
|
<tr><td align="center" class="rpttext" nowrap>Updated: Thu Jun 11 12:45:00 UTC 2026 (<a href="archive/day1-geojson.zip">geojson</a>)</td></tr>
|
||||||
|
</table>
|
||||||
<pre>
|
<pre>
|
||||||
<script>window.bad = "<b>ignore me</b>";</script>
|
<script>window.bad = "<b>ignore me</b>";</script>
|
||||||
|
SPC AC 111234
|
||||||
Day 1 Convective Outlook
|
Day 1 Convective Outlook
|
||||||
NWS Storm Prediction Center Norman OK
|
NWS Storm Prediction Center Norman OK
|
||||||
Updated: 2026-06-11T12:45:00Z
|
|
||||||
|
|
||||||
...SUMMARY...
|
...SUMMARY...
|
||||||
Severe thunderstorms are possible across parts of the central Plains
|
Severe thunderstorms are possible across parts of the central Plains
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
|
<table>
|
||||||
|
<tr><td class="rpttext">Updated: Thu Jun 11 17:30:00 UTC 2026 </td></tr>
|
||||||
|
</table>
|
||||||
<pre>
|
<pre>
|
||||||
|
SPC AC 111730
|
||||||
Day 2 Convective Outlook CORR 1
|
Day 2 Convective Outlook CORR 1
|
||||||
NWS Storm Prediction Center Norman OK
|
NWS Storm Prediction Center Norman OK
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
"EXPIRE_ISO": "2026-06-14T12:00:00Z",
|
"EXPIRE_ISO": "2026-06-14T12:00:00Z",
|
||||||
"ISSUE_ISO": "2026-06-11T19:45:00Z",
|
"ISSUE_ISO": "2026-06-11T19:45:00Z",
|
||||||
"FORECASTER": "LEE",
|
"FORECASTER": "LEE",
|
||||||
"LABEL": "15",
|
"LABEL": "MRGL",
|
||||||
"LABEL2": "15% Wind",
|
"LABEL2": "Marginal Risk",
|
||||||
"DN": 15
|
"DN": 2
|
||||||
},
|
},
|
||||||
"geometry": {
|
"geometry": {
|
||||||
"type": "MultiPolygon",
|
"type": "MultiPolygon",
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
|
<table>
|
||||||
|
<tr><td class="rpttext">Updated: Thu Jun 11 20:00:00 UTC 2026 </td></tr>
|
||||||
|
</table>
|
||||||
<pre>
|
<pre>
|
||||||
|
SPC AC 112000
|
||||||
Day 3 Convective Outlook
|
Day 3 Convective Outlook
|
||||||
NWS Storm Prediction Center Norman OK
|
NWS Storm Prediction Center Norman OK
|
||||||
Updated: 2026-06-11T20:00:00Z
|
|
||||||
|
|
||||||
...SUMMARY...
|
...SUMMARY...
|
||||||
A corridor of strong to severe storms may develop near a frontal zone.
|
A corridor of strong to severe storms may develop near a frontal zone.
|
||||||
|
|||||||
@@ -233,6 +233,8 @@
|
|||||||
// - run_event_id TEXT -> outlook_runs.event_id / payload.outlooks[i]
|
// - run_event_id TEXT -> outlook_runs.event_id / payload.outlooks[i]
|
||||||
// - outlook_index INTEGER -> i (array position in payload.outlooks)
|
// - outlook_index INTEGER -> i (array position in payload.outlooks)
|
||||||
// - as_of TIMESTAMPTZ -> payload.asOf (copied from parent)
|
// - as_of TIMESTAMPTZ -> payload.asOf (copied from parent)
|
||||||
|
// - outlook_id TEXT -> payload.outlooks[i].id
|
||||||
|
// - provider TEXT -> payload.outlooks[i].provider
|
||||||
// - product TEXT -> payload.outlooks[i].product
|
// - product TEXT -> payload.outlooks[i].product
|
||||||
// - day INTEGER -> payload.outlooks[i].day
|
// - day INTEGER -> payload.outlooks[i].day
|
||||||
// - outlook_type TEXT -> payload.outlooks[i].outlookType
|
// - outlook_type TEXT -> payload.outlooks[i].outlookType
|
||||||
|
|||||||
@@ -48,13 +48,7 @@ func mapObservationEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
|||||||
|
|
||||||
writes = append(writes, fksinks.PostgresWrite{
|
writes = append(writes, fksinks.PostgresWrite{
|
||||||
Table: tableObservations,
|
Table: tableObservations,
|
||||||
Values: map[string]any{
|
Values: parentEventValues(e, map[string]any{
|
||||||
"event_id": e.ID,
|
|
||||||
"event_kind": string(e.Kind),
|
|
||||||
"event_source": e.Source,
|
|
||||||
"event_schema": e.Schema,
|
|
||||||
"event_emitted_at": e.EmittedAt.UTC(),
|
|
||||||
"event_effective_at": nullableTime(e.EffectiveAt),
|
|
||||||
"station_id": nullableString(obs.StationID),
|
"station_id": nullableString(obs.StationID),
|
||||||
"station_name": nullableString(obs.StationName),
|
"station_name": nullableString(obs.StationName),
|
||||||
"observed_at": observedAt,
|
"observed_at": observedAt,
|
||||||
@@ -70,7 +64,7 @@ func mapObservationEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
|||||||
"visibility_meters": nullableFloat64(obs.VisibilityMeters),
|
"visibility_meters": nullableFloat64(obs.VisibilityMeters),
|
||||||
"relative_humidity_percent": nullableFloat64(obs.RelativeHumidityPercent),
|
"relative_humidity_percent": nullableFloat64(obs.RelativeHumidityPercent),
|
||||||
"apparent_temperature_c": nullableFloat64(obs.ApparentTemperatureC),
|
"apparent_temperature_c": nullableFloat64(obs.ApparentTemperatureC),
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
for i, pw := range obs.PresentWeather {
|
for i, pw := range obs.PresentWeather {
|
||||||
@@ -109,13 +103,7 @@ func mapForecastEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
|||||||
|
|
||||||
writes = append(writes, fksinks.PostgresWrite{
|
writes = append(writes, fksinks.PostgresWrite{
|
||||||
Table: tableForecasts,
|
Table: tableForecasts,
|
||||||
Values: map[string]any{
|
Values: parentEventValues(e, map[string]any{
|
||||||
"event_id": e.ID,
|
|
||||||
"event_kind": string(e.Kind),
|
|
||||||
"event_source": e.Source,
|
|
||||||
"event_schema": e.Schema,
|
|
||||||
"event_emitted_at": e.EmittedAt.UTC(),
|
|
||||||
"event_effective_at": nullableTime(e.EffectiveAt),
|
|
||||||
"location_id": nullableString(run.LocationID),
|
"location_id": nullableString(run.LocationID),
|
||||||
"location_name": nullableString(run.LocationName),
|
"location_name": nullableString(run.LocationName),
|
||||||
"issued_at": issuedAt,
|
"issued_at": issuedAt,
|
||||||
@@ -125,7 +113,7 @@ func mapForecastEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
|||||||
"longitude": nullableFloat64(run.Longitude),
|
"longitude": nullableFloat64(run.Longitude),
|
||||||
"elevation_meters": nullableFloat64(run.ElevationMeters),
|
"elevation_meters": nullableFloat64(run.ElevationMeters),
|
||||||
"period_count": len(run.Periods),
|
"period_count": len(run.Periods),
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
for i, p := range run.Periods {
|
for i, p := range run.Periods {
|
||||||
@@ -186,13 +174,7 @@ func mapForecastDiscussionEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error
|
|||||||
writes := make([]fksinks.PostgresWrite, 0, 1+len(run.KeyMessages))
|
writes := make([]fksinks.PostgresWrite, 0, 1+len(run.KeyMessages))
|
||||||
writes = append(writes, fksinks.PostgresWrite{
|
writes = append(writes, fksinks.PostgresWrite{
|
||||||
Table: tableForecastDiscussions,
|
Table: tableForecastDiscussions,
|
||||||
Values: map[string]any{
|
Values: parentEventValues(e, map[string]any{
|
||||||
"event_id": e.ID,
|
|
||||||
"event_kind": string(e.Kind),
|
|
||||||
"event_source": e.Source,
|
|
||||||
"event_schema": e.Schema,
|
|
||||||
"event_emitted_at": e.EmittedAt.UTC(),
|
|
||||||
"event_effective_at": nullableTime(e.EffectiveAt),
|
|
||||||
"office_id": nullableString(run.OfficeID),
|
"office_id": nullableString(run.OfficeID),
|
||||||
"office_name": nullableString(run.OfficeName),
|
"office_name": nullableString(run.OfficeName),
|
||||||
"issued_at": issuedAt,
|
"issued_at": issuedAt,
|
||||||
@@ -205,7 +187,7 @@ func mapForecastDiscussionEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error
|
|||||||
"long_term_issued_at": longTermIssuedAt,
|
"long_term_issued_at": longTermIssuedAt,
|
||||||
"long_term_text": longTermText,
|
"long_term_text": longTermText,
|
||||||
"key_message_count": len(run.KeyMessages),
|
"key_message_count": len(run.KeyMessages),
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
for i, msg := range run.KeyMessages {
|
for i, msg := range run.KeyMessages {
|
||||||
@@ -236,17 +218,11 @@ func mapWeatherStoryEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
|||||||
writes := make([]fksinks.PostgresWrite, 0, 1+len(run.Stories))
|
writes := make([]fksinks.PostgresWrite, 0, 1+len(run.Stories))
|
||||||
writes = append(writes, fksinks.PostgresWrite{
|
writes = append(writes, fksinks.PostgresWrite{
|
||||||
Table: tableWeatherStoryRuns,
|
Table: tableWeatherStoryRuns,
|
||||||
Values: map[string]any{
|
Values: parentEventValues(e, map[string]any{
|
||||||
"event_id": e.ID,
|
|
||||||
"event_kind": string(e.Kind),
|
|
||||||
"event_source": e.Source,
|
|
||||||
"event_schema": e.Schema,
|
|
||||||
"event_emitted_at": e.EmittedAt.UTC(),
|
|
||||||
"event_effective_at": nullableTime(e.EffectiveAt),
|
|
||||||
"office_id": nullableString(run.OfficeID),
|
"office_id": nullableString(run.OfficeID),
|
||||||
"as_of": asOf,
|
"as_of": asOf,
|
||||||
"story_count": len(run.Stories),
|
"story_count": len(run.Stories),
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
for i, story := range run.Stories {
|
for i, story := range run.Stories {
|
||||||
@@ -290,20 +266,14 @@ func mapAlertEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
|||||||
|
|
||||||
writes = append(writes, fksinks.PostgresWrite{
|
writes = append(writes, fksinks.PostgresWrite{
|
||||||
Table: tableAlertRuns,
|
Table: tableAlertRuns,
|
||||||
Values: map[string]any{
|
Values: parentEventValues(e, map[string]any{
|
||||||
"event_id": e.ID,
|
|
||||||
"event_kind": string(e.Kind),
|
|
||||||
"event_source": e.Source,
|
|
||||||
"event_schema": e.Schema,
|
|
||||||
"event_emitted_at": e.EmittedAt.UTC(),
|
|
||||||
"event_effective_at": nullableTime(e.EffectiveAt),
|
|
||||||
"location_id": nullableString(run.LocationID),
|
"location_id": nullableString(run.LocationID),
|
||||||
"location_name": nullableString(run.LocationName),
|
"location_name": nullableString(run.LocationName),
|
||||||
"as_of": asOf,
|
"as_of": asOf,
|
||||||
"latitude": nullableFloat64(run.Latitude),
|
"latitude": nullableFloat64(run.Latitude),
|
||||||
"longitude": nullableFloat64(run.Longitude),
|
"longitude": nullableFloat64(run.Longitude),
|
||||||
"alert_count": len(run.Alerts),
|
"alert_count": len(run.Alerts),
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
for i, a := range run.Alerts {
|
for i, a := range run.Alerts {
|
||||||
@@ -372,13 +342,7 @@ func mapOutlookEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
|||||||
writes := make([]fksinks.PostgresWrite, 0, 1+len(run.Outlooks))
|
writes := make([]fksinks.PostgresWrite, 0, 1+len(run.Outlooks))
|
||||||
writes = append(writes, fksinks.PostgresWrite{
|
writes = append(writes, fksinks.PostgresWrite{
|
||||||
Table: tableOutlookRuns,
|
Table: tableOutlookRuns,
|
||||||
Values: map[string]any{
|
Values: parentEventValues(e, map[string]any{
|
||||||
"event_id": e.ID,
|
|
||||||
"event_kind": string(e.Kind),
|
|
||||||
"event_source": e.Source,
|
|
||||||
"event_schema": e.Schema,
|
|
||||||
"event_emitted_at": e.EmittedAt.UTC(),
|
|
||||||
"event_effective_at": nullableTime(e.EffectiveAt),
|
|
||||||
"location_id": nullableString(run.LocationID),
|
"location_id": nullableString(run.LocationID),
|
||||||
"location_name": nullableString(run.LocationName),
|
"location_name": nullableString(run.LocationName),
|
||||||
"latitude": nullableFloat64(run.Latitude),
|
"latitude": nullableFloat64(run.Latitude),
|
||||||
@@ -386,7 +350,7 @@ func mapOutlookEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
|||||||
"as_of": asOf,
|
"as_of": asOf,
|
||||||
"issued_at": nullableTime(run.IssuedAt),
|
"issued_at": nullableTime(run.IssuedAt),
|
||||||
"outlook_count": len(run.Outlooks),
|
"outlook_count": len(run.Outlooks),
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
for i, outlook := range run.Outlooks {
|
for i, outlook := range run.Outlooks {
|
||||||
@@ -404,6 +368,8 @@ func mapOutlookEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
|||||||
"run_event_id": e.ID,
|
"run_event_id": e.ID,
|
||||||
"outlook_index": i,
|
"outlook_index": i,
|
||||||
"as_of": asOf,
|
"as_of": asOf,
|
||||||
|
"outlook_id": outlook.ID,
|
||||||
|
"provider": outlook.Provider,
|
||||||
"product": outlook.Product,
|
"product": outlook.Product,
|
||||||
"day": outlook.Day,
|
"day": outlook.Day,
|
||||||
"outlook_type": outlook.OutlookType,
|
"outlook_type": outlook.OutlookType,
|
||||||
@@ -486,6 +452,21 @@ func decodePayload[T any](payload any) (T, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parentEventValues(e fkevent.Event, values map[string]any) map[string]any {
|
||||||
|
out := map[string]any{
|
||||||
|
"event_id": e.ID,
|
||||||
|
"event_kind": string(e.Kind),
|
||||||
|
"event_source": e.Source,
|
||||||
|
"event_schema": e.Schema,
|
||||||
|
"event_emitted_at": e.EmittedAt.UTC(),
|
||||||
|
"event_effective_at": nullableTime(e.EffectiveAt),
|
||||||
|
}
|
||||||
|
for k, v := range values {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func nullableDiscussionSection(section *model.WeatherForecastDiscussionSection) (any, any, any) {
|
func nullableDiscussionSection(section *model.WeatherForecastDiscussionSection) (any, any, any) {
|
||||||
if section == nil {
|
if section == nil {
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func TestMapPostgresEventObservationStructPayload(t *testing.T) {
|
|||||||
PresentWeather: []model.PresentWeather{{Raw: map[string]any{"a": 1, "b": "x"}}},
|
PresentWeather: []model.PresentWeather{{Raw: map[string]any{"a": 1, "b": "x"}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherObservationV1, "observation", obs))
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherObservationV1, standards.KindObservation, obs))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mapPostgresEvent() error = %v", err)
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ func TestMapPostgresEventForecastStructPayload(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastV1, "forecast", run))
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastV1, standards.KindForecast, run))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mapPostgresEvent() error = %v", err)
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ func TestMapPostgresEventAlertStructPayload(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherAlertV1, "alert", run))
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherAlertV1, standards.KindAlert, run))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mapPostgresEvent() error = %v", err)
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ func TestMapPostgresEventForecastDiscussionStructPayload(t *testing.T) {
|
|||||||
LongTerm: &model.WeatherForecastDiscussionSection{Text: "Long term text"},
|
LongTerm: &model.WeatherForecastDiscussionSection{Text: "Long term text"},
|
||||||
}
|
}
|
||||||
|
|
||||||
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastDiscussionV1, "forecast_discussion", run))
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastDiscussionV1, standards.KindForecastDiscussion, run))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mapPostgresEvent() error = %v", err)
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -213,7 +213,7 @@ func TestMapPostgresEventWeatherStoryStructPayload(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherStoryV1, "weather_story", run))
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherStoryV1, standards.KindWeatherStory, run))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mapPostgresEvent() error = %v", err)
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -290,7 +290,7 @@ func TestMapPostgresEventOutlookStructPayload(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, "outlook", run))
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, standards.KindOutlook, run))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mapPostgresEvent() error = %v", err)
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -312,6 +312,12 @@ func TestMapPostgresEventOutlookStructPayload(t *testing.T) {
|
|||||||
if got := writes[1].Values["outlook_index"]; got != 0 {
|
if got := writes[1].Values["outlook_index"]; got != 0 {
|
||||||
t.Fatalf("first outlook index = %#v, want 0", got)
|
t.Fatalf("first outlook index = %#v, want 0", got)
|
||||||
}
|
}
|
||||||
|
if got := writes[1].Values["outlook_id"]; got != "outlook-1" {
|
||||||
|
t.Fatalf("first outlook_id = %#v, want outlook-1", got)
|
||||||
|
}
|
||||||
|
if got := writes[1].Values["provider"]; got != "spc" {
|
||||||
|
t.Fatalf("first provider = %#v, want spc", got)
|
||||||
|
}
|
||||||
if got := writes[1].Values["valid_from"]; got != run.Outlooks[0].ValidFrom.UTC() {
|
if got := writes[1].Values["valid_from"]; got != run.Outlooks[0].ValidFrom.UTC() {
|
||||||
t.Fatalf("first valid_from = %#v, want UTC %s", got, run.Outlooks[0].ValidFrom.UTC())
|
t.Fatalf("first valid_from = %#v, want UTC %s", got, run.Outlooks[0].ValidFrom.UTC())
|
||||||
}
|
}
|
||||||
@@ -326,7 +332,7 @@ func TestMapPostgresEventOutlookStructPayload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMapPostgresEventOutlookRejectsMissingAsOf(t *testing.T) {
|
func TestMapPostgresEventOutlookRejectsMissingAsOf(t *testing.T) {
|
||||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, "outlook", model.WeatherOutlookRun{}))
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, standards.KindOutlook, model.WeatherOutlookRun{}))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("mapPostgresEvent() error = nil, want missing asOf error")
|
t.Fatalf("mapPostgresEvent() error = nil, want missing asOf error")
|
||||||
}
|
}
|
||||||
@@ -335,6 +341,57 @@ func TestMapPostgresEventOutlookRejectsMissingAsOf(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMapPostgresEventOutlookRejectsMissingIDAndProvider(t *testing.T) {
|
||||||
|
base := model.WeatherOutlook{
|
||||||
|
ID: "outlook-1",
|
||||||
|
Provider: "spc",
|
||||||
|
Product: "convective",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "SLGT",
|
||||||
|
ValidFrom: time.Date(2026, 6, 11, 13, 0, 0, 0, time.UTC),
|
||||||
|
ValidTo: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
||||||
|
IssuedAt: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||||
|
ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
||||||
|
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*model.WeatherOutlook)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing id",
|
||||||
|
mutate: func(outlook *model.WeatherOutlook) { outlook.ID = "" },
|
||||||
|
wantErr: "outlooks[0].id is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing provider",
|
||||||
|
mutate: func(outlook *model.WeatherOutlook) { outlook.Provider = "" },
|
||||||
|
wantErr: "outlooks[0].provider is required",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
outlook := base
|
||||||
|
tt.mutate(&outlook)
|
||||||
|
run := model.WeatherOutlookRun{
|
||||||
|
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||||
|
Outlooks: []model.WeatherOutlook{outlook},
|
||||||
|
}
|
||||||
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, standards.KindOutlook, run))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("mapPostgresEvent() error = nil, want %q", tt.wantErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("error = %q, want %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMapPostgresEventOutlookRejectsMissingRequiredTimes(t *testing.T) {
|
func TestMapPostgresEventOutlookRejectsMissingRequiredTimes(t *testing.T) {
|
||||||
run := model.WeatherOutlookRun{
|
run := model.WeatherOutlookRun{
|
||||||
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
||||||
@@ -348,7 +405,7 @@ func TestMapPostgresEventOutlookRejectsMissingRequiredTimes(t *testing.T) {
|
|||||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`),
|
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`),
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, "outlook", run))
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, standards.KindOutlook, run))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("mapPostgresEvent() error = nil, want missing time error")
|
t.Fatalf("mapPostgresEvent() error = nil, want missing time error")
|
||||||
}
|
}
|
||||||
@@ -373,7 +430,7 @@ func TestMapPostgresEventOutlookRejectsEmptyGeometry(t *testing.T) {
|
|||||||
ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, "outlook", run))
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, standards.KindOutlook, run))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("mapPostgresEvent() error = nil, want geometry error")
|
t.Fatalf("mapPostgresEvent() error = nil, want geometry error")
|
||||||
}
|
}
|
||||||
@@ -383,7 +440,7 @@ func TestMapPostgresEventOutlookRejectsEmptyGeometry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMapPostgresEventWeatherStoryRejectsMissingAsOf(t *testing.T) {
|
func TestMapPostgresEventWeatherStoryRejectsMissingAsOf(t *testing.T) {
|
||||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherStoryV1, "weather_story", model.WeatherStoryRun{}))
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherStoryV1, standards.KindWeatherStory, model.WeatherStoryRun{}))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("mapPostgresEvent() error = nil, want missing asOf error")
|
t.Fatalf("mapPostgresEvent() error = nil, want missing asOf error")
|
||||||
}
|
}
|
||||||
@@ -397,7 +454,7 @@ func TestMapPostgresEventWeatherStoryRejectsMissingStoryTimes(t *testing.T) {
|
|||||||
AsOf: time.Date(2026, 5, 30, 9, 0, 34, 0, time.UTC),
|
AsOf: time.Date(2026, 5, 30, 9, 0, 34, 0, time.UTC),
|
||||||
Stories: []model.WeatherStory{{Title: "missing times"}},
|
Stories: []model.WeatherStory{{Title: "missing times"}},
|
||||||
}
|
}
|
||||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherStoryV1, "weather_story", run))
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherStoryV1, standards.KindWeatherStory, run))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("mapPostgresEvent() error = nil, want missing story times error")
|
t.Fatalf("mapPostgresEvent() error = nil, want missing story times error")
|
||||||
}
|
}
|
||||||
@@ -427,7 +484,7 @@ func TestMapPostgresEventMapPayload(t *testing.T) {
|
|||||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastV1, "forecast", payload))
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastV1, standards.KindForecast, payload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mapPostgresEvent() error = %v", err)
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -442,7 +499,7 @@ func TestMapPostgresEventMapPayload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMapPostgresEventUnknownSchemaNoOp(t *testing.T) {
|
func TestMapPostgresEventUnknownSchemaNoOp(t *testing.T) {
|
||||||
writes, err := mapPostgresEvent(context.Background(), testEvent("weather.unknown.v1", "observation", map[string]any{"x": 1}))
|
writes, err := mapPostgresEvent(context.Background(), testEvent("weather.unknown.v1", standards.KindObservation, map[string]any{"x": 1}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mapPostgresEvent() error = %v", err)
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -452,7 +509,7 @@ func TestMapPostgresEventUnknownSchemaNoOp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMapPostgresEventMalformedPayload(t *testing.T) {
|
func TestMapPostgresEventMalformedPayload(t *testing.T) {
|
||||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastV1, "forecast", "bad"))
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastV1, standards.KindForecast, "bad"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("mapPostgresEvent() expected error for malformed payload")
|
t.Fatalf("mapPostgresEvent() expected error for malformed payload")
|
||||||
}
|
}
|
||||||
@@ -462,7 +519,7 @@ func TestMapPostgresEventMalformedPayload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMapPostgresEventForecastDiscussionMalformedPayload(t *testing.T) {
|
func TestMapPostgresEventForecastDiscussionMalformedPayload(t *testing.T) {
|
||||||
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastDiscussionV1, "forecast_discussion", "bad"))
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastDiscussionV1, standards.KindForecastDiscussion, "bad"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("mapPostgresEvent() expected error for malformed payload")
|
t.Fatalf("mapPostgresEvent() expected error for malformed payload")
|
||||||
}
|
}
|
||||||
@@ -471,6 +528,56 @@ func TestMapPostgresEventForecastDiscussionMalformedPayload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParentEventValuesAddsEnvelopeAndPreservesProductValues(t *testing.T) {
|
||||||
|
emittedAt := time.Date(2026, 3, 16, 13, 31, 0, 0, time.FixedZone("CDT", -5*60*60))
|
||||||
|
effectiveAt := time.Date(2026, 3, 16, 13, 30, 0, 0, time.FixedZone("CDT", -5*60*60))
|
||||||
|
event := fkevent.Event{
|
||||||
|
ID: "evt-envelope",
|
||||||
|
Kind: fkevent.Kind(standards.KindForecast),
|
||||||
|
Source: "test-source",
|
||||||
|
Schema: standards.SchemaWeatherForecastV1,
|
||||||
|
EmittedAt: emittedAt,
|
||||||
|
EffectiveAt: &effectiveAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := parentEventValues(event, map[string]any{"product_col": "product-value"})
|
||||||
|
|
||||||
|
assertParentEnvelopeValues(t, got, event)
|
||||||
|
if got["product_col"] != "product-value" {
|
||||||
|
t.Fatalf("product_col = %#v, want product-value", got["product_col"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParentEventValuesNullEffectiveAt(t *testing.T) {
|
||||||
|
base := fkevent.Event{
|
||||||
|
ID: "evt-envelope",
|
||||||
|
Kind: fkevent.Kind(standards.KindObservation),
|
||||||
|
Source: "test-source",
|
||||||
|
Schema: standards.SchemaWeatherObservationV1,
|
||||||
|
EmittedAt: time.Date(2026, 3, 16, 18, 31, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
mut func(*fkevent.Event)
|
||||||
|
}{
|
||||||
|
{name: "nil", mut: func(*fkevent.Event) {}},
|
||||||
|
{name: "zero", mut: func(event *fkevent.Event) {
|
||||||
|
zero := time.Time{}
|
||||||
|
event.EffectiveAt = &zero
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
event := base
|
||||||
|
tt.mut(&event)
|
||||||
|
got := parentEventValues(event, nil)
|
||||||
|
if got["event_effective_at"] != nil {
|
||||||
|
t.Fatalf("event_effective_at = %#v, want nil", got["event_effective_at"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testEvent(schema string, kind fkevent.Kind, payload any) fkevent.Event {
|
func testEvent(schema string, kind fkevent.Kind, payload any) fkevent.Event {
|
||||||
effectiveAt := time.Date(2026, 3, 16, 18, 30, 0, 0, time.UTC)
|
effectiveAt := time.Date(2026, 3, 16, 18, 30, 0, 0, time.UTC)
|
||||||
return fkevent.Event{
|
return fkevent.Event{
|
||||||
@@ -484,6 +591,30 @@ func testEvent(schema string, kind fkevent.Kind, payload any) fkevent.Event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertParentEnvelopeValues(t *testing.T, values map[string]any, event fkevent.Event) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if got := values["event_id"]; got != event.ID {
|
||||||
|
t.Fatalf("event_id = %#v, want %q", got, event.ID)
|
||||||
|
}
|
||||||
|
if got := values["event_kind"]; got != string(event.Kind) {
|
||||||
|
t.Fatalf("event_kind = %#v, want %q", got, event.Kind)
|
||||||
|
}
|
||||||
|
if got := values["event_source"]; got != event.Source {
|
||||||
|
t.Fatalf("event_source = %#v, want %q", got, event.Source)
|
||||||
|
}
|
||||||
|
if got := values["event_schema"]; got != event.Schema {
|
||||||
|
t.Fatalf("event_schema = %#v, want %q", got, event.Schema)
|
||||||
|
}
|
||||||
|
if got := values["event_emitted_at"]; got != event.EmittedAt.UTC() {
|
||||||
|
t.Fatalf("event_emitted_at = %#v, want %s", got, event.EmittedAt.UTC())
|
||||||
|
}
|
||||||
|
wantEffective := nullableTime(event.EffectiveAt)
|
||||||
|
if got := values["event_effective_at"]; got != wantEffective {
|
||||||
|
t.Fatalf("event_effective_at = %#v, want %#v", got, wantEffective)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func firstWriteForTable(writes []fksinks.PostgresWrite, table string) (fksinks.PostgresWrite, bool) {
|
func firstWriteForTable(writes []fksinks.PostgresWrite, table string) (fksinks.PostgresWrite, bool) {
|
||||||
for _, w := range writes {
|
for _, w := range writes {
|
||||||
if w.Table == table {
|
if w.Table == table {
|
||||||
|
|||||||
@@ -26,13 +26,7 @@ func PostgresSchema() fksinks.PostgresSchema {
|
|||||||
Tables: []fksinks.PostgresTable{
|
Tables: []fksinks.PostgresTable{
|
||||||
{
|
{
|
||||||
Name: tableObservations,
|
Name: tableObservations,
|
||||||
Columns: []fksinks.PostgresColumn{
|
Columns: parentEnvelopeColumns([]fksinks.PostgresColumn{
|
||||||
{Name: "event_id", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_kind", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_source", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_schema", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_emitted_at", Type: "TIMESTAMPTZ", Nullable: false},
|
|
||||||
{Name: "event_effective_at", Type: "TIMESTAMPTZ", Nullable: true},
|
|
||||||
{Name: "station_id", Type: "TEXT", Nullable: true},
|
{Name: "station_id", Type: "TEXT", Nullable: true},
|
||||||
{Name: "station_name", Type: "TEXT", Nullable: true},
|
{Name: "station_name", Type: "TEXT", Nullable: true},
|
||||||
{Name: "observed_at", Type: "TIMESTAMPTZ", Nullable: false},
|
{Name: "observed_at", Type: "TIMESTAMPTZ", Nullable: false},
|
||||||
@@ -48,7 +42,7 @@ func PostgresSchema() fksinks.PostgresSchema {
|
|||||||
{Name: "visibility_meters", Type: "DOUBLE PRECISION", Nullable: true},
|
{Name: "visibility_meters", Type: "DOUBLE PRECISION", Nullable: true},
|
||||||
{Name: "relative_humidity_percent", Type: "DOUBLE PRECISION", Nullable: true},
|
{Name: "relative_humidity_percent", Type: "DOUBLE PRECISION", Nullable: true},
|
||||||
{Name: "apparent_temperature_c", Type: "DOUBLE PRECISION", Nullable: true},
|
{Name: "apparent_temperature_c", Type: "DOUBLE PRECISION", Nullable: true},
|
||||||
},
|
}...),
|
||||||
PrimaryKey: []string{"event_id"},
|
PrimaryKey: []string{"event_id"},
|
||||||
PruneColumn: "observed_at",
|
PruneColumn: "observed_at",
|
||||||
Indexes: []fksinks.PostgresIndex{
|
Indexes: []fksinks.PostgresIndex{
|
||||||
@@ -73,13 +67,7 @@ func PostgresSchema() fksinks.PostgresSchema {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: tableForecasts,
|
Name: tableForecasts,
|
||||||
Columns: []fksinks.PostgresColumn{
|
Columns: parentEnvelopeColumns([]fksinks.PostgresColumn{
|
||||||
{Name: "event_id", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_kind", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_source", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_schema", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_emitted_at", Type: "TIMESTAMPTZ", Nullable: false},
|
|
||||||
{Name: "event_effective_at", Type: "TIMESTAMPTZ", Nullable: true},
|
|
||||||
{Name: "location_id", Type: "TEXT", Nullable: true},
|
{Name: "location_id", Type: "TEXT", Nullable: true},
|
||||||
{Name: "location_name", Type: "TEXT", Nullable: true},
|
{Name: "location_name", Type: "TEXT", Nullable: true},
|
||||||
{Name: "issued_at", Type: "TIMESTAMPTZ", Nullable: false},
|
{Name: "issued_at", Type: "TIMESTAMPTZ", Nullable: false},
|
||||||
@@ -89,7 +77,7 @@ func PostgresSchema() fksinks.PostgresSchema {
|
|||||||
{Name: "longitude", Type: "DOUBLE PRECISION", Nullable: true},
|
{Name: "longitude", Type: "DOUBLE PRECISION", Nullable: true},
|
||||||
{Name: "elevation_meters", Type: "DOUBLE PRECISION", Nullable: true},
|
{Name: "elevation_meters", Type: "DOUBLE PRECISION", Nullable: true},
|
||||||
{Name: "period_count", Type: "INTEGER", Nullable: false},
|
{Name: "period_count", Type: "INTEGER", Nullable: false},
|
||||||
},
|
}...),
|
||||||
PrimaryKey: []string{"event_id"},
|
PrimaryKey: []string{"event_id"},
|
||||||
PruneColumn: "issued_at",
|
PruneColumn: "issued_at",
|
||||||
Indexes: []fksinks.PostgresIndex{
|
Indexes: []fksinks.PostgresIndex{
|
||||||
@@ -137,13 +125,7 @@ func PostgresSchema() fksinks.PostgresSchema {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: tableForecastDiscussions,
|
Name: tableForecastDiscussions,
|
||||||
Columns: []fksinks.PostgresColumn{
|
Columns: parentEnvelopeColumns([]fksinks.PostgresColumn{
|
||||||
{Name: "event_id", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_kind", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_source", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_schema", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_emitted_at", Type: "TIMESTAMPTZ", Nullable: false},
|
|
||||||
{Name: "event_effective_at", Type: "TIMESTAMPTZ", Nullable: true},
|
|
||||||
{Name: "office_id", Type: "TEXT", Nullable: true},
|
{Name: "office_id", Type: "TEXT", Nullable: true},
|
||||||
{Name: "office_name", Type: "TEXT", Nullable: true},
|
{Name: "office_name", Type: "TEXT", Nullable: true},
|
||||||
{Name: "issued_at", Type: "TIMESTAMPTZ", Nullable: false},
|
{Name: "issued_at", Type: "TIMESTAMPTZ", Nullable: false},
|
||||||
@@ -156,7 +138,7 @@ func PostgresSchema() fksinks.PostgresSchema {
|
|||||||
{Name: "long_term_issued_at", Type: "TIMESTAMPTZ", Nullable: true},
|
{Name: "long_term_issued_at", Type: "TIMESTAMPTZ", Nullable: true},
|
||||||
{Name: "long_term_text", Type: "TEXT", Nullable: true},
|
{Name: "long_term_text", Type: "TEXT", Nullable: true},
|
||||||
{Name: "key_message_count", Type: "INTEGER", Nullable: false},
|
{Name: "key_message_count", Type: "INTEGER", Nullable: false},
|
||||||
},
|
}...),
|
||||||
PrimaryKey: []string{"event_id"},
|
PrimaryKey: []string{"event_id"},
|
||||||
PruneColumn: "issued_at",
|
PruneColumn: "issued_at",
|
||||||
Indexes: []fksinks.PostgresIndex{
|
Indexes: []fksinks.PostgresIndex{
|
||||||
@@ -180,17 +162,11 @@ func PostgresSchema() fksinks.PostgresSchema {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: tableWeatherStoryRuns,
|
Name: tableWeatherStoryRuns,
|
||||||
Columns: []fksinks.PostgresColumn{
|
Columns: parentEnvelopeColumns([]fksinks.PostgresColumn{
|
||||||
{Name: "event_id", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_kind", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_source", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_schema", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_emitted_at", Type: "TIMESTAMPTZ", Nullable: false},
|
|
||||||
{Name: "event_effective_at", Type: "TIMESTAMPTZ", Nullable: true},
|
|
||||||
{Name: "office_id", Type: "TEXT", Nullable: true},
|
{Name: "office_id", Type: "TEXT", Nullable: true},
|
||||||
{Name: "as_of", Type: "TIMESTAMPTZ", Nullable: false},
|
{Name: "as_of", Type: "TIMESTAMPTZ", Nullable: false},
|
||||||
{Name: "story_count", Type: "INTEGER", Nullable: false},
|
{Name: "story_count", Type: "INTEGER", Nullable: false},
|
||||||
},
|
}...),
|
||||||
PrimaryKey: []string{"event_id"},
|
PrimaryKey: []string{"event_id"},
|
||||||
PruneColumn: "as_of",
|
PruneColumn: "as_of",
|
||||||
Indexes: []fksinks.PostgresIndex{
|
Indexes: []fksinks.PostgresIndex{
|
||||||
@@ -225,20 +201,14 @@ func PostgresSchema() fksinks.PostgresSchema {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: tableAlertRuns,
|
Name: tableAlertRuns,
|
||||||
Columns: []fksinks.PostgresColumn{
|
Columns: parentEnvelopeColumns([]fksinks.PostgresColumn{
|
||||||
{Name: "event_id", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_kind", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_source", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_schema", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_emitted_at", Type: "TIMESTAMPTZ", Nullable: false},
|
|
||||||
{Name: "event_effective_at", Type: "TIMESTAMPTZ", Nullable: true},
|
|
||||||
{Name: "location_id", Type: "TEXT", Nullable: true},
|
{Name: "location_id", Type: "TEXT", Nullable: true},
|
||||||
{Name: "location_name", Type: "TEXT", Nullable: true},
|
{Name: "location_name", Type: "TEXT", Nullable: true},
|
||||||
{Name: "as_of", Type: "TIMESTAMPTZ", Nullable: false},
|
{Name: "as_of", Type: "TIMESTAMPTZ", Nullable: false},
|
||||||
{Name: "latitude", Type: "DOUBLE PRECISION", Nullable: true},
|
{Name: "latitude", Type: "DOUBLE PRECISION", Nullable: true},
|
||||||
{Name: "longitude", Type: "DOUBLE PRECISION", Nullable: true},
|
{Name: "longitude", Type: "DOUBLE PRECISION", Nullable: true},
|
||||||
{Name: "alert_count", Type: "INTEGER", Nullable: false},
|
{Name: "alert_count", Type: "INTEGER", Nullable: false},
|
||||||
},
|
}...),
|
||||||
PrimaryKey: []string{"event_id"},
|
PrimaryKey: []string{"event_id"},
|
||||||
PruneColumn: "as_of",
|
PruneColumn: "as_of",
|
||||||
Indexes: []fksinks.PostgresIndex{
|
Indexes: []fksinks.PostgresIndex{
|
||||||
@@ -301,13 +271,7 @@ func PostgresSchema() fksinks.PostgresSchema {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: tableOutlookRuns,
|
Name: tableOutlookRuns,
|
||||||
Columns: []fksinks.PostgresColumn{
|
Columns: parentEnvelopeColumns([]fksinks.PostgresColumn{
|
||||||
{Name: "event_id", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_kind", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_source", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_schema", Type: "TEXT", Nullable: false},
|
|
||||||
{Name: "event_emitted_at", Type: "TIMESTAMPTZ", Nullable: false},
|
|
||||||
{Name: "event_effective_at", Type: "TIMESTAMPTZ", Nullable: true},
|
|
||||||
{Name: "location_id", Type: "TEXT", Nullable: true},
|
{Name: "location_id", Type: "TEXT", Nullable: true},
|
||||||
{Name: "location_name", Type: "TEXT", Nullable: true},
|
{Name: "location_name", Type: "TEXT", Nullable: true},
|
||||||
{Name: "latitude", Type: "DOUBLE PRECISION", Nullable: true},
|
{Name: "latitude", Type: "DOUBLE PRECISION", Nullable: true},
|
||||||
@@ -315,7 +279,7 @@ func PostgresSchema() fksinks.PostgresSchema {
|
|||||||
{Name: "as_of", Type: "TIMESTAMPTZ", Nullable: false},
|
{Name: "as_of", Type: "TIMESTAMPTZ", Nullable: false},
|
||||||
{Name: "issued_at", Type: "TIMESTAMPTZ", Nullable: true},
|
{Name: "issued_at", Type: "TIMESTAMPTZ", Nullable: true},
|
||||||
{Name: "outlook_count", Type: "INTEGER", Nullable: false},
|
{Name: "outlook_count", Type: "INTEGER", Nullable: false},
|
||||||
},
|
}...),
|
||||||
PrimaryKey: []string{"event_id"},
|
PrimaryKey: []string{"event_id"},
|
||||||
PruneColumn: "as_of",
|
PruneColumn: "as_of",
|
||||||
Indexes: []fksinks.PostgresIndex{
|
Indexes: []fksinks.PostgresIndex{
|
||||||
@@ -329,6 +293,8 @@ func PostgresSchema() fksinks.PostgresSchema {
|
|||||||
{Name: "run_event_id", Type: "TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE", Nullable: false},
|
{Name: "run_event_id", Type: "TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE", Nullable: false},
|
||||||
{Name: "outlook_index", Type: "INTEGER", Nullable: false},
|
{Name: "outlook_index", Type: "INTEGER", Nullable: false},
|
||||||
{Name: "as_of", Type: "TIMESTAMPTZ", Nullable: false},
|
{Name: "as_of", Type: "TIMESTAMPTZ", Nullable: false},
|
||||||
|
{Name: "outlook_id", Type: "TEXT", Nullable: false},
|
||||||
|
{Name: "provider", Type: "TEXT", Nullable: false},
|
||||||
{Name: "product", Type: "TEXT", Nullable: false},
|
{Name: "product", Type: "TEXT", Nullable: false},
|
||||||
{Name: "day", Type: "INTEGER", Nullable: false},
|
{Name: "day", Type: "INTEGER", Nullable: false},
|
||||||
{Name: "outlook_type", Type: "TEXT", Nullable: false},
|
{Name: "outlook_type", Type: "TEXT", Nullable: false},
|
||||||
@@ -360,3 +326,15 @@ func PostgresSchema() fksinks.PostgresSchema {
|
|||||||
MapEvent: mapPostgresEvent,
|
MapEvent: mapPostgresEvent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parentEnvelopeColumns(extra ...fksinks.PostgresColumn) []fksinks.PostgresColumn {
|
||||||
|
columns := []fksinks.PostgresColumn{
|
||||||
|
{Name: "event_id", Type: "TEXT", Nullable: false},
|
||||||
|
{Name: "event_kind", Type: "TEXT", Nullable: false},
|
||||||
|
{Name: "event_source", Type: "TEXT", Nullable: false},
|
||||||
|
{Name: "event_schema", Type: "TEXT", Nullable: false},
|
||||||
|
{Name: "event_emitted_at", Type: "TIMESTAMPTZ", Nullable: false},
|
||||||
|
{Name: "event_effective_at", Type: "TIMESTAMPTZ", Nullable: true},
|
||||||
|
}
|
||||||
|
return append(columns, extra...)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
fksinks "gitea.maximumdirect.net/ejr/feedkit/sinks"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWeatherPostgresSchemaShape(t *testing.T) {
|
func TestWeatherPostgresSchemaShape(t *testing.T) {
|
||||||
@@ -60,7 +63,7 @@ func TestWeatherPostgresSchemaIncludesOutlookTables(t *testing.T) {
|
|||||||
assertTableIndex(t, tableOutlookRuns, "idx_wf_outlook_run_as_of", []string{"as_of"})
|
assertTableIndex(t, tableOutlookRuns, "idx_wf_outlook_run_as_of", []string{"as_of"})
|
||||||
|
|
||||||
outlookColumns := columnsForTable(t, tableOutlooks)
|
outlookColumns := columnsForTable(t, tableOutlooks)
|
||||||
for _, col := range []string{"run_event_id", "outlook_index", "as_of", "product", "day", "outlook_type", "label", "label_text", "severity_rank", "valid_from", "valid_to", "issued_at", "expires_at", "forecaster", "headline", "summary", "discussion", "source_url", "image_url", "contains_location", "geometry_json"} {
|
for _, col := range []string{"run_event_id", "outlook_index", "as_of", "outlook_id", "provider", "product", "day", "outlook_type", "label", "label_text", "severity_rank", "valid_from", "valid_to", "issued_at", "expires_at", "forecaster", "headline", "summary", "discussion", "source_url", "image_url", "contains_location", "geometry_json"} {
|
||||||
if !outlookColumns[col] {
|
if !outlookColumns[col] {
|
||||||
t.Fatalf("%s missing %s column", tableOutlooks, col)
|
t.Fatalf("%s missing %s column", tableOutlooks, col)
|
||||||
}
|
}
|
||||||
@@ -88,6 +91,28 @@ func TestWeatherPostgresSchemaIncludesWeatherStoryColumns(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWeatherPostgresSchemaParentTablesStartWithEnvelopeColumns(t *testing.T) {
|
||||||
|
for _, table := range []string{
|
||||||
|
tableObservations,
|
||||||
|
tableForecasts,
|
||||||
|
tableForecastDiscussions,
|
||||||
|
tableWeatherStoryRuns,
|
||||||
|
tableAlertRuns,
|
||||||
|
tableOutlookRuns,
|
||||||
|
} {
|
||||||
|
t.Run(table, func(t *testing.T) {
|
||||||
|
columns := orderedColumnsForTable(t, table)
|
||||||
|
want := parentEnvelopeColumns()
|
||||||
|
if len(columns) < len(want) {
|
||||||
|
t.Fatalf("%s has %d columns, want at least %d", table, len(columns), len(want))
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(columns[:len(want)], want) {
|
||||||
|
t.Fatalf("%s envelope prefix = %#v, want %#v", table, columns[:len(want)], want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func assertTablePrimaryKey(t *testing.T, table string, want []string) {
|
func assertTablePrimaryKey(t *testing.T, table string, want []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, tbl := range PostgresSchema().Tables {
|
for _, tbl := range PostgresSchema().Tables {
|
||||||
@@ -121,20 +146,26 @@ func assertTableIndex(t *testing.T, table string, name string, want []string) {
|
|||||||
t.Fatalf("missing table %q", table)
|
t.Fatalf("missing table %q", table)
|
||||||
}
|
}
|
||||||
|
|
||||||
func columnsForTable(t *testing.T, table string) map[string]bool {
|
func orderedColumnsForTable(t *testing.T, table string) []fksinks.PostgresColumn {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
schema := PostgresSchema()
|
schema := PostgresSchema()
|
||||||
for _, tbl := range schema.Tables {
|
for _, tbl := range schema.Tables {
|
||||||
if tbl.Name != table {
|
if tbl.Name == table {
|
||||||
continue
|
return tbl.Columns
|
||||||
}
|
}
|
||||||
cols := make(map[string]bool, len(tbl.Columns))
|
|
||||||
for _, col := range tbl.Columns {
|
|
||||||
cols[col.Name] = true
|
|
||||||
}
|
|
||||||
return cols
|
|
||||||
}
|
}
|
||||||
t.Fatalf("missing table %q", table)
|
t.Fatalf("missing table %q", table)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func columnsForTable(t *testing.T, table string) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ordered := orderedColumnsForTable(t, table)
|
||||||
|
cols := make(map[string]bool, len(ordered))
|
||||||
|
for _, col := range ordered {
|
||||||
|
cols[col.Name] = true
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,20 +16,20 @@ type pollDriverRegistration struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var pollDriverRegistrations = []pollDriverRegistration{
|
var pollDriverRegistrations = []pollDriverRegistration{
|
||||||
{driver: "nws_observation", factory: func(cfg config.SourceConfig) (fksource.PollSource, error) { return nws.NewObservationSource(cfg) }},
|
{driver: nws.DriverObservation, factory: func(cfg config.SourceConfig) (fksource.PollSource, error) { return nws.NewObservationSource(cfg) }},
|
||||||
{driver: "nws_alerts", factory: func(cfg config.SourceConfig) (fksource.PollSource, error) { return nws.NewAlertsSource(cfg) }},
|
{driver: nws.DriverAlerts, factory: func(cfg config.SourceConfig) (fksource.PollSource, error) { return nws.NewAlertsSource(cfg) }},
|
||||||
{driver: "nws_forecast_hourly", factory: func(cfg config.SourceConfig) (fksource.PollSource, error) { return nws.NewHourlyForecastSource(cfg) }},
|
{driver: nws.DriverForecastHourly, factory: func(cfg config.SourceConfig) (fksource.PollSource, error) { return nws.NewHourlyForecastSource(cfg) }},
|
||||||
{driver: "nws_forecast_narrative", factory: func(cfg config.SourceConfig) (fksource.PollSource, error) { return nws.NewNarrativeForecastSource(cfg) }},
|
{driver: nws.DriverForecastNarrative, factory: func(cfg config.SourceConfig) (fksource.PollSource, error) { return nws.NewNarrativeForecastSource(cfg) }},
|
||||||
{driver: "nws_forecast_discussion", factory: func(cfg config.SourceConfig) (fksource.PollSource, error) {
|
{driver: nws.DriverForecastDiscussion, factory: func(cfg config.SourceConfig) (fksource.PollSource, error) {
|
||||||
return nws.NewForecastDiscussionSource(cfg)
|
return nws.NewForecastDiscussionSource(cfg)
|
||||||
}},
|
}},
|
||||||
{driver: "nws_weatherstories", factory: func(cfg config.SourceConfig) (fksource.PollSource, error) { return nws.NewWeatherStoriesSource(cfg) }},
|
{driver: nws.DriverWeatherStories, factory: func(cfg config.SourceConfig) (fksource.PollSource, error) { return nws.NewWeatherStoriesSource(cfg) }},
|
||||||
{driver: "openmeteo_observation", factory: func(cfg config.SourceConfig) (fksource.PollSource, error) { return openmeteo.NewObservationSource(cfg) }},
|
{driver: openmeteo.DriverObservation, factory: func(cfg config.SourceConfig) (fksource.PollSource, error) { return openmeteo.NewObservationSource(cfg) }},
|
||||||
{driver: "openmeteo_forecast", factory: func(cfg config.SourceConfig) (fksource.PollSource, error) { return openmeteo.NewForecastSource(cfg) }},
|
{driver: openmeteo.DriverForecast, factory: func(cfg config.SourceConfig) (fksource.PollSource, error) { return openmeteo.NewForecastSource(cfg) }},
|
||||||
{driver: "openweather_observation", factory: func(cfg config.SourceConfig) (fksource.PollSource, error) {
|
{driver: openweather.DriverObservation, factory: func(cfg config.SourceConfig) (fksource.PollSource, error) {
|
||||||
return openweather.NewObservationSource(cfg)
|
return openweather.NewObservationSource(cfg)
|
||||||
}},
|
}},
|
||||||
{driver: "spc_convective_outlook", factory: func(cfg config.SourceConfig) (fksource.PollSource, error) {
|
{driver: spc.DriverConvectiveOutlook, factory: func(cfg config.SourceConfig) (fksource.PollSource, error) {
|
||||||
return spc.NewConvectiveOutlookSource(cfg)
|
return spc.NewConvectiveOutlookSource(cfg)
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,57 +6,29 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/feedkit/config"
|
"gitea.maximumdirect.net/ejr/feedkit/config"
|
||||||
fksource "gitea.maximumdirect.net/ejr/feedkit/sources"
|
fksource "gitea.maximumdirect.net/ejr/feedkit/sources"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/openweather"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/spc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRegisterBuiltinsRegistersNWSHourlyForecastDriver(t *testing.T) {
|
func TestRegisterBuiltinsRegistersCurrentPollDrivers(t *testing.T) {
|
||||||
reg := fksource.NewRegistry()
|
reg := fksource.NewRegistry()
|
||||||
RegisterBuiltins(reg)
|
RegisterBuiltins(reg)
|
||||||
|
|
||||||
in, err := reg.BuildInput(sourceConfigForDriver("nws_forecast_hourly"))
|
if len(pollDriverRegistrations) == 0 {
|
||||||
|
t.Fatalf("pollDriverRegistrations is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range pollDriverRegistrations {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.driver, func(t *testing.T) {
|
||||||
|
in, err := reg.BuildInput(sourceConfigForDriver(tt.driver))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("BuildInput(nws_forecast_hourly) error = %v", err)
|
t.Fatalf("BuildInput(%q) error = %v", tt.driver, err)
|
||||||
}
|
}
|
||||||
if _, ok := in.(fksource.PollSource); !ok {
|
if _, ok := in.(fksource.PollSource); !ok {
|
||||||
t.Fatalf("BuildInput(nws_forecast_hourly) type = %T, want PollSource", in)
|
t.Fatalf("BuildInput(%q) type = %T, want PollSource", tt.driver, in)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
func TestRegisterBuiltinsRegistersNWSNarrativeForecastDriver(t *testing.T) {
|
|
||||||
reg := fksource.NewRegistry()
|
|
||||||
RegisterBuiltins(reg)
|
|
||||||
|
|
||||||
in, err := reg.BuildInput(sourceConfigForDriver("nws_forecast_narrative"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildInput(nws_forecast_narrative) error = %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := in.(fksource.PollSource); !ok {
|
|
||||||
t.Fatalf("BuildInput(nws_forecast_narrative) type = %T, want PollSource", in)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterBuiltinsRegistersNWSForecastDiscussionDriver(t *testing.T) {
|
|
||||||
reg := fksource.NewRegistry()
|
|
||||||
RegisterBuiltins(reg)
|
|
||||||
|
|
||||||
in, err := reg.BuildInput(sourceConfigForDriver("nws_forecast_discussion"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildInput(nws_forecast_discussion) error = %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := in.(fksource.PollSource); !ok {
|
|
||||||
t.Fatalf("BuildInput(nws_forecast_discussion) type = %T, want PollSource", in)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterBuiltinsRegistersNWSWeatherStoriesDriver(t *testing.T) {
|
|
||||||
reg := fksource.NewRegistry()
|
|
||||||
RegisterBuiltins(reg)
|
|
||||||
|
|
||||||
in, err := reg.BuildInput(sourceConfigForDriver("nws_weatherstories"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildInput(nws_weatherstories) error = %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := in.(fksource.PollSource); !ok {
|
|
||||||
t.Fatalf("BuildInput(nws_weatherstories) type = %T, want PollSource", in)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,44 +45,16 @@ func TestRegisterBuiltinsDoesNotRegisterLegacyNWSForecastDriver(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterBuiltinsRegistersAllCurrentDrivers(t *testing.T) {
|
|
||||||
reg := fksource.NewRegistry()
|
|
||||||
RegisterBuiltins(reg)
|
|
||||||
|
|
||||||
drivers := []string{
|
|
||||||
"nws_observation",
|
|
||||||
"nws_alerts",
|
|
||||||
"nws_forecast_hourly",
|
|
||||||
"nws_forecast_narrative",
|
|
||||||
"nws_forecast_discussion",
|
|
||||||
"nws_weatherstories",
|
|
||||||
"openmeteo_observation",
|
|
||||||
"openmeteo_forecast",
|
|
||||||
"openweather_observation",
|
|
||||||
"spc_convective_outlook",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, driver := range drivers {
|
|
||||||
in, err := reg.BuildInput(sourceConfigForDriver(driver))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildInput(%s) error = %v", driver, err)
|
|
||||||
}
|
|
||||||
if _, ok := in.(fksource.PollSource); !ok {
|
|
||||||
t.Fatalf("BuildInput(%s) type = %T, want PollSource", driver, in)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceConfigForDriver(driver string) config.SourceConfig {
|
func sourceConfigForDriver(driver string) config.SourceConfig {
|
||||||
url := "https://example.invalid"
|
url := "https://example.invalid"
|
||||||
if driver == "openweather_observation" {
|
if driver == openweather.DriverObservation {
|
||||||
url = "https://example.invalid?units=metric"
|
url = "https://example.invalid?units=metric"
|
||||||
}
|
}
|
||||||
params := map[string]any{
|
params := map[string]any{
|
||||||
"url": url,
|
"url": url,
|
||||||
"user_agent": "test-agent",
|
"user_agent": "test-agent",
|
||||||
}
|
}
|
||||||
if driver == "spc_convective_outlook" {
|
if driver == spc.DriverConvectiveOutlook {
|
||||||
params["latitude"] = 38.6239
|
params["latitude"] = 38.6239
|
||||||
params["longitude"] = -90.3571
|
params["longitude"] = -90.3571
|
||||||
}
|
}
|
||||||
|
|||||||
32
internal/sources/docs_test.go
Normal file
32
internal/sources/docs_test.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package sources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDocumentedRegisteredSourceDrivers(t *testing.T) {
|
||||||
|
docs := map[string]string{
|
||||||
|
"docs/config.md": readDoc(t, "../../docs/config.md"),
|
||||||
|
"docs/internal/sources.md": readDoc(t, "../../docs/internal/sources.md"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, reg := range pollDriverRegistrations {
|
||||||
|
for path, doc := range docs {
|
||||||
|
if !strings.Contains(doc, reg.driver) {
|
||||||
|
t.Fatalf("%s missing source driver %q", path, reg.driver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDoc(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%s) error = %v", path, err)
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
58
internal/sources/internal/httpconfig/config.go
Normal file
58
internal/sources/internal/httpconfig/config.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package httpconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/feedkit/config"
|
||||||
|
"gitea.maximumdirect.net/ejr/feedkit/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settings contains common HTTP client config for sources that fetch multiple documents.
|
||||||
|
type Settings struct {
|
||||||
|
Name string
|
||||||
|
UserAgent string
|
||||||
|
Timeout time.Duration
|
||||||
|
BodyLimitBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func Parse(driver string, cfg config.SourceConfig) (Settings, error) {
|
||||||
|
name := strings.TrimSpace(cfg.Name)
|
||||||
|
if name == "" {
|
||||||
|
return Settings{}, fmt.Errorf("%s: name is required", driver)
|
||||||
|
}
|
||||||
|
if cfg.Params == nil {
|
||||||
|
return Settings{}, fmt.Errorf("%s %q: params are required", driver, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
userAgent, ok := cfg.ParamString("user_agent", "userAgent")
|
||||||
|
if !ok {
|
||||||
|
return Settings{}, fmt.Errorf("%s %q: params.user_agent is required", driver, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := transport.DefaultHTTPTimeout
|
||||||
|
if _, exists := cfg.Params["http_timeout"]; exists {
|
||||||
|
var ok bool
|
||||||
|
timeout, ok = cfg.ParamDuration("http_timeout")
|
||||||
|
if !ok || timeout <= 0 {
|
||||||
|
return Settings{}, fmt.Errorf("source %q: params.http_timeout must be a positive duration", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyLimit := transport.DefaultHTTPResponseBodyLimitBytes
|
||||||
|
if _, exists := cfg.Params["http_response_body_limit_bytes"]; exists {
|
||||||
|
rawLimit, ok := cfg.ParamInt("http_response_body_limit_bytes")
|
||||||
|
if !ok || rawLimit <= 0 {
|
||||||
|
return Settings{}, fmt.Errorf("source %q: params.http_response_body_limit_bytes must be a positive integer", name)
|
||||||
|
}
|
||||||
|
bodyLimit = int64(rawLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Settings{
|
||||||
|
Name: name,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
Timeout: timeout,
|
||||||
|
BodyLimitBytes: bodyLimit,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
115
internal/sources/internal/httpconfig/config_test.go
Normal file
115
internal/sources/internal/httpconfig/config_test.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package httpconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/feedkit/config"
|
||||||
|
"gitea.maximumdirect.net/ejr/feedkit/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseUsesRequiredValuesAndDefaults(t *testing.T) {
|
||||||
|
got, err := Parse("test_driver", config.SourceConfig{
|
||||||
|
Name: " test-source ",
|
||||||
|
Params: map[string]any{
|
||||||
|
"user_agent": "test-agent",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "test-source" {
|
||||||
|
t.Fatalf("Name = %q, want test-source", got.Name)
|
||||||
|
}
|
||||||
|
if got.UserAgent != "test-agent" {
|
||||||
|
t.Fatalf("UserAgent = %q, want test-agent", got.UserAgent)
|
||||||
|
}
|
||||||
|
if got.Timeout != transport.DefaultHTTPTimeout {
|
||||||
|
t.Fatalf("Timeout = %s, want %s", got.Timeout, transport.DefaultHTTPTimeout)
|
||||||
|
}
|
||||||
|
if got.BodyLimitBytes != transport.DefaultHTTPResponseBodyLimitBytes {
|
||||||
|
t.Fatalf("BodyLimitBytes = %d, want %d", got.BodyLimitBytes, transport.DefaultHTTPResponseBodyLimitBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUsesAliasesAndOverrides(t *testing.T) {
|
||||||
|
got, err := Parse("test_driver", config.SourceConfig{
|
||||||
|
Name: "test-source",
|
||||||
|
Params: map[string]any{
|
||||||
|
"userAgent": "test-agent",
|
||||||
|
"http_timeout": "2s",
|
||||||
|
"http_response_body_limit_bytes": 2048,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.UserAgent != "test-agent" {
|
||||||
|
t.Fatalf("UserAgent = %q, want test-agent", got.UserAgent)
|
||||||
|
}
|
||||||
|
if got.Timeout != 2*time.Second {
|
||||||
|
t.Fatalf("Timeout = %s, want 2s", got.Timeout)
|
||||||
|
}
|
||||||
|
if got.BodyLimitBytes != 2048 {
|
||||||
|
t.Fatalf("BodyLimitBytes = %d, want 2048", got.BodyLimitBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRejectsInvalidConfig(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg config.SourceConfig
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing name",
|
||||||
|
cfg: config.SourceConfig{Params: map[string]any{"user_agent": "test-agent"}},
|
||||||
|
wantErr: "test_driver: name is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing params",
|
||||||
|
cfg: config.SourceConfig{Name: "test-source"},
|
||||||
|
wantErr: `test_driver "test-source": params are required`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing user agent",
|
||||||
|
cfg: config.SourceConfig{Name: "test-source", Params: map[string]any{}},
|
||||||
|
wantErr: `test_driver "test-source": params.user_agent is required`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid timeout",
|
||||||
|
cfg: config.SourceConfig{
|
||||||
|
Name: "test-source",
|
||||||
|
Params: map[string]any{
|
||||||
|
"user_agent": "test-agent",
|
||||||
|
"http_timeout": "0s",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: `source "test-source": params.http_timeout must be a positive duration`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid body limit",
|
||||||
|
cfg: config.SourceConfig{
|
||||||
|
Name: "test-source",
|
||||||
|
Params: map[string]any{
|
||||||
|
"user_agent": "test-agent",
|
||||||
|
"http_response_body_limit_bytes": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: `source "test-source": params.http_response_body_limit_bytes must be a positive integer`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := Parse("test_driver", tt.cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Parse() error = nil, want %q", tt.wantErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("Parse() error = %q, want %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,10 +26,8 @@ type AlertsSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewAlertsSource(cfg config.SourceConfig) (*AlertsSource, error) {
|
func NewAlertsSource(cfg config.SourceConfig) (*AlertsSource, error) {
|
||||||
const driver = "nws_alerts"
|
|
||||||
|
|
||||||
// NWS alerts responses are GeoJSON-ish; allow fallback to plain JSON as well.
|
// NWS alerts responses are GeoJSON-ish; allow fallback to plain JSON as well.
|
||||||
hs, err := fksources.NewHTTPSource(driver, cfg, "application/geo+json, application/json")
|
hs, err := fksources.NewHTTPSource(DriverAlerts, cfg, "application/geo+json, application/json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -40,7 +38,7 @@ func NewAlertsSource(cfg config.SourceConfig) (*AlertsSource, error) {
|
|||||||
func (s *AlertsSource) Name() string { return s.http.Name }
|
func (s *AlertsSource) Name() string { return s.http.Name }
|
||||||
|
|
||||||
// Kinds is used for routing/policy.
|
// Kinds is used for routing/policy.
|
||||||
func (s *AlertsSource) Kinds() []event.Kind { return []event.Kind{event.Kind("alert")} }
|
func (s *AlertsSource) Kinds() []event.Kind { return []event.Kind{event.Kind(standards.KindAlert)} }
|
||||||
|
|
||||||
func (s *AlertsSource) Poll(ctx context.Context) ([]event.Event, error) {
|
func (s *AlertsSource) Poll(ctx context.Context) ([]event.Event, error) {
|
||||||
raw, meta, changed, err := s.fetchRaw(ctx)
|
raw, meta, changed, err := s.fetchRaw(ctx)
|
||||||
@@ -71,7 +69,7 @@ func (s *AlertsSource) Poll(ctx context.Context) ([]event.Event, error) {
|
|||||||
eventID := fksources.DefaultEventID("", s.http.Name, effectiveAt, emittedAt)
|
eventID := fksources.DefaultEventID("", s.http.Name, effectiveAt, emittedAt)
|
||||||
|
|
||||||
return fksources.SingleEvent(
|
return fksources.SingleEvent(
|
||||||
event.Kind("alert"),
|
event.Kind(standards.KindAlert),
|
||||||
s.http.Name,
|
s.http.Name,
|
||||||
standards.SchemaRawNWSAlertsV1,
|
standards.SchemaRawNWSAlertsV1,
|
||||||
eventID,
|
eventID,
|
||||||
|
|||||||
11
internal/sources/nws/driver.go
Normal file
11
internal/sources/nws/driver.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package nws
|
||||||
|
|
||||||
|
// Source driver strings registered by weatherfeeder for NWS sources.
|
||||||
|
const (
|
||||||
|
DriverObservation = "nws_observation"
|
||||||
|
DriverAlerts = "nws_alerts"
|
||||||
|
DriverForecastHourly = "nws_forecast_hourly"
|
||||||
|
DriverForecastNarrative = "nws_forecast_narrative"
|
||||||
|
DriverForecastDiscussion = "nws_forecast_discussion"
|
||||||
|
DriverWeatherStories = "nws_weatherstories"
|
||||||
|
)
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||||
fksources "gitea.maximumdirect.net/ejr/feedkit/sources"
|
fksources "gitea.maximumdirect.net/ejr/feedkit/sources"
|
||||||
nwscommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/nws"
|
nwscommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/nws"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||||
)
|
)
|
||||||
|
|
||||||
const nwsForecastAccept = "application/geo+json, application/json"
|
const nwsForecastAccept = "application/geo+json, application/json"
|
||||||
@@ -44,7 +45,9 @@ func newForecastSource(cfg config.SourceConfig, driver, rawSchema string) (*fore
|
|||||||
|
|
||||||
func (s *forecastSource) Name() string { return s.http.Name }
|
func (s *forecastSource) Name() string { return s.http.Name }
|
||||||
|
|
||||||
func (s *forecastSource) Kinds() []event.Kind { return []event.Kind{event.Kind("forecast")} }
|
func (s *forecastSource) Kinds() []event.Kind {
|
||||||
|
return []event.Kind{event.Kind(standards.KindForecast)}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *forecastSource) Poll(ctx context.Context) ([]event.Event, error) {
|
func (s *forecastSource) Poll(ctx context.Context) ([]event.Event, error) {
|
||||||
raw, meta, changed, err := s.fetchRaw(ctx)
|
raw, meta, changed, err := s.fetchRaw(ctx)
|
||||||
@@ -69,7 +72,7 @@ func (s *forecastSource) Poll(ctx context.Context) ([]event.Event, error) {
|
|||||||
eventID := fksources.DefaultEventID("", s.http.Name, effectiveAt, emittedAt)
|
eventID := fksources.DefaultEventID("", s.http.Name, effectiveAt, emittedAt)
|
||||||
|
|
||||||
return fksources.SingleEvent(
|
return fksources.SingleEvent(
|
||||||
event.Kind("forecast"),
|
event.Kind(standards.KindForecast),
|
||||||
s.http.Name,
|
s.http.Name,
|
||||||
s.rawSchema,
|
s.rawSchema,
|
||||||
eventID,
|
eventID,
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ type ForecastDiscussionSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewForecastDiscussionSource(cfg config.SourceConfig) (*ForecastDiscussionSource, error) {
|
func NewForecastDiscussionSource(cfg config.SourceConfig) (*ForecastDiscussionSource, error) {
|
||||||
const driver = "nws_forecast_discussion"
|
hs, err := fksources.NewHTTPSource(DriverForecastDiscussion, cfg, "text/html, application/xhtml+xml")
|
||||||
|
|
||||||
hs, err := fksources.NewHTTPSource(driver, cfg, "text/html, application/xhtml+xml")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -33,7 +31,7 @@ func NewForecastDiscussionSource(cfg config.SourceConfig) (*ForecastDiscussionSo
|
|||||||
func (s *ForecastDiscussionSource) Name() string { return s.http.Name }
|
func (s *ForecastDiscussionSource) Name() string { return s.http.Name }
|
||||||
|
|
||||||
func (s *ForecastDiscussionSource) Kinds() []event.Kind {
|
func (s *ForecastDiscussionSource) Kinds() []event.Kind {
|
||||||
return []event.Kind{event.Kind("forecast_discussion")}
|
return []event.Kind{event.Kind(standards.KindForecastDiscussion)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ForecastDiscussionSource) Poll(ctx context.Context) ([]event.Event, error) {
|
func (s *ForecastDiscussionSource) Poll(ctx context.Context) ([]event.Event, error) {
|
||||||
@@ -57,7 +55,7 @@ func (s *ForecastDiscussionSource) Poll(ctx context.Context) ([]event.Event, err
|
|||||||
eventID := fksources.DefaultEventID("", s.http.Name, effectiveAt, emittedAt)
|
eventID := fksources.DefaultEventID("", s.http.Name, effectiveAt, emittedAt)
|
||||||
|
|
||||||
return fksources.SingleEvent(
|
return fksources.SingleEvent(
|
||||||
event.Kind("forecast_discussion"),
|
event.Kind(standards.KindForecastDiscussion),
|
||||||
s.http.Name,
|
s.http.Name,
|
||||||
standards.SchemaRawNWSForecastDiscussionV1,
|
standards.SchemaRawNWSForecastDiscussionV1,
|
||||||
eventID,
|
eventID,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func TestForecastDiscussionSourcePollEmitsExpectedEvent(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewForecastDiscussionSource() error = %v", err)
|
t.Fatalf("NewForecastDiscussionSource() error = %v", err)
|
||||||
}
|
}
|
||||||
if got := src.Kinds(); len(got) != 1 || got[0] != event.Kind("forecast_discussion") {
|
if got := src.Kinds(); len(got) != 1 || got[0] != event.Kind(standards.KindForecastDiscussion) {
|
||||||
t.Fatalf("Kinds() = %#v, want [forecast_discussion]", got)
|
t.Fatalf("Kinds() = %#v, want [forecast_discussion]", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ func TestForecastDiscussionSourcePollEmitsExpectedEvent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
got := events[0]
|
got := events[0]
|
||||||
if got.Kind != event.Kind("forecast_discussion") {
|
if got.Kind != event.Kind(standards.KindForecastDiscussion) {
|
||||||
t.Fatalf("Kind = %q, want forecast_discussion", got.Kind)
|
t.Fatalf("Kind = %q, want forecast_discussion", got.Kind)
|
||||||
}
|
}
|
||||||
if got.Schema != standards.SchemaRawNWSForecastDiscussionV1 {
|
if got.Schema != standards.SchemaRawNWSForecastDiscussionV1 {
|
||||||
@@ -117,7 +117,7 @@ func TestForecastDiscussionSourcePollRejectsInvalidHTML(t *testing.T) {
|
|||||||
func forecastDiscussionSourceConfig(url string) config.SourceConfig {
|
func forecastDiscussionSourceConfig(url string) config.SourceConfig {
|
||||||
return config.SourceConfig{
|
return config.SourceConfig{
|
||||||
Name: "test-forecast-discussion-source",
|
Name: "test-forecast-discussion-source",
|
||||||
Driver: "nws_forecast_discussion",
|
Driver: DriverForecastDiscussion,
|
||||||
Mode: config.SourceModePoll,
|
Mode: config.SourceModePoll,
|
||||||
Params: map[string]any{
|
Params: map[string]any{
|
||||||
"url": url,
|
"url": url,
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ type HourlyForecastSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewHourlyForecastSource(cfg config.SourceConfig) (*HourlyForecastSource, error) {
|
func NewHourlyForecastSource(cfg config.SourceConfig) (*HourlyForecastSource, error) {
|
||||||
const driver = "nws_forecast_hourly"
|
src, err := newForecastSource(cfg, DriverForecastHourly, standards.SchemaRawNWSHourlyForecastV1)
|
||||||
src, err := newForecastSource(cfg, driver, standards.SchemaRawNWSHourlyForecastV1)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ type NarrativeForecastSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewNarrativeForecastSource(cfg config.SourceConfig) (*NarrativeForecastSource, error) {
|
func NewNarrativeForecastSource(cfg config.SourceConfig) (*NarrativeForecastSource, error) {
|
||||||
const driver = "nws_forecast_narrative"
|
src, err := newForecastSource(cfg, DriverForecastNarrative, standards.SchemaRawNWSNarrativeForecastV1)
|
||||||
src, err := newForecastSource(cfg, driver, standards.SchemaRawNWSNarrativeForecastV1)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func TestForecastSourcesEmitExpectedSchemaAndPreferGeneratedAt(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "hourly",
|
name: "hourly",
|
||||||
driver: "nws_forecast_hourly",
|
driver: DriverForecastHourly,
|
||||||
wantSchema: standards.SchemaRawNWSHourlyForecastV1,
|
wantSchema: standards.SchemaRawNWSHourlyForecastV1,
|
||||||
newSource: func(cfg config.SourceConfig) (forecastPoller, error) {
|
newSource: func(cfg config.SourceConfig) (forecastPoller, error) {
|
||||||
return NewHourlyForecastSource(cfg)
|
return NewHourlyForecastSource(cfg)
|
||||||
@@ -34,7 +34,7 @@ func TestForecastSourcesEmitExpectedSchemaAndPreferGeneratedAt(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "narrative",
|
name: "narrative",
|
||||||
driver: "nws_forecast_narrative",
|
driver: DriverForecastNarrative,
|
||||||
wantSchema: standards.SchemaRawNWSNarrativeForecastV1,
|
wantSchema: standards.SchemaRawNWSNarrativeForecastV1,
|
||||||
newSource: func(cfg config.SourceConfig) (forecastPoller, error) {
|
newSource: func(cfg config.SourceConfig) (forecastPoller, error) {
|
||||||
return NewNarrativeForecastSource(cfg)
|
return NewNarrativeForecastSource(cfg)
|
||||||
@@ -55,7 +55,7 @@ func TestForecastSourcesEmitExpectedSchemaAndPreferGeneratedAt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if ks, ok := src.(interface{ Kinds() []event.Kind }); !ok {
|
if ks, ok := src.(interface{ Kinds() []event.Kind }); !ok {
|
||||||
t.Fatalf("source does not implement Kinds()")
|
t.Fatalf("source does not implement Kinds()")
|
||||||
} else if gotKinds := ks.Kinds(); len(gotKinds) != 1 || gotKinds[0] != event.Kind("forecast") {
|
} else if gotKinds := ks.Kinds(); len(gotKinds) != 1 || gotKinds[0] != event.Kind(standards.KindForecast) {
|
||||||
t.Fatalf("Kinds() = %#v, want [forecast]", gotKinds)
|
t.Fatalf("Kinds() = %#v, want [forecast]", gotKinds)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ func TestForecastSourcesEmitExpectedSchemaAndPreferGeneratedAt(t *testing.T) {
|
|||||||
if got[0].Schema != tt.wantSchema {
|
if got[0].Schema != tt.wantSchema {
|
||||||
t.Fatalf("Poll() schema = %q, want %q", got[0].Schema, tt.wantSchema)
|
t.Fatalf("Poll() schema = %q, want %q", got[0].Schema, tt.wantSchema)
|
||||||
}
|
}
|
||||||
if got[0].Kind != event.Kind("forecast") {
|
if got[0].Kind != event.Kind(standards.KindForecast) {
|
||||||
t.Fatalf("Poll() kind = %q, want forecast", got[0].Kind)
|
t.Fatalf("Poll() kind = %q, want forecast", got[0].Kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ func TestForecastSourcePollEffectiveAtFallbackOrder(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
src, err := NewHourlyForecastSource(forecastSourceConfig("nws_forecast_hourly", srv.URL))
|
src, err := NewHourlyForecastSource(forecastSourceConfig(DriverForecastHourly, srv.URL))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewHourlyForecastSource() error = %v", err)
|
t.Fatalf("NewHourlyForecastSource() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ func TestForecastSourcePollMetadataDecodeFailureStillEmitsRawEvent(t *testing.T)
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
src, err := NewNarrativeForecastSource(forecastSourceConfig("nws_forecast_narrative", srv.URL))
|
src, err := NewNarrativeForecastSource(forecastSourceConfig(DriverForecastNarrative, srv.URL))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewNarrativeForecastSource() error = %v", err)
|
t.Fatalf("NewNarrativeForecastSource() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ type ObservationSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewObservationSource(cfg config.SourceConfig) (*ObservationSource, error) {
|
func NewObservationSource(cfg config.SourceConfig) (*ObservationSource, error) {
|
||||||
const driver = "nws_observation"
|
hs, err := fksources.NewHTTPSource(DriverObservation, cfg, "application/geo+json, application/json")
|
||||||
|
|
||||||
hs, err := fksources.NewHTTPSource(driver, cfg, "application/geo+json, application/json")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -32,7 +30,9 @@ func NewObservationSource(cfg config.SourceConfig) (*ObservationSource, error) {
|
|||||||
|
|
||||||
func (s *ObservationSource) Name() string { return s.http.Name }
|
func (s *ObservationSource) Name() string { return s.http.Name }
|
||||||
|
|
||||||
func (s *ObservationSource) Kinds() []event.Kind { return []event.Kind{event.Kind("observation")} }
|
func (s *ObservationSource) Kinds() []event.Kind {
|
||||||
|
return []event.Kind{event.Kind(standards.KindObservation)}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) {
|
func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) {
|
||||||
raw, meta, changed, err := s.fetchRaw(ctx)
|
raw, meta, changed, err := s.fetchRaw(ctx)
|
||||||
@@ -54,7 +54,7 @@ func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) {
|
|||||||
eventID := fksources.DefaultEventID(meta.ID, s.http.Name, effectiveAt, emittedAt)
|
eventID := fksources.DefaultEventID(meta.ID, s.http.Name, effectiveAt, emittedAt)
|
||||||
|
|
||||||
return fksources.SingleEvent(
|
return fksources.SingleEvent(
|
||||||
event.Kind("observation"),
|
event.Kind(standards.KindObservation),
|
||||||
s.http.Name,
|
s.http.Name,
|
||||||
standards.SchemaRawNWSObservationV1,
|
standards.SchemaRawNWSObservationV1,
|
||||||
eventID,
|
eventID,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/feedkit/config"
|
"gitea.maximumdirect.net/ejr/feedkit/config"
|
||||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestObservationSourcePollReturnsNoEventsOn304(t *testing.T) {
|
func TestObservationSourcePollReturnsNoEventsOn304(t *testing.T) {
|
||||||
@@ -31,7 +32,7 @@ func TestObservationSourcePollReturnsNoEventsOn304(t *testing.T) {
|
|||||||
|
|
||||||
src, err := NewObservationSource(config.SourceConfig{
|
src, err := NewObservationSource(config.SourceConfig{
|
||||||
Name: "NWSObservationTest",
|
Name: "NWSObservationTest",
|
||||||
Driver: "nws_observation",
|
Driver: DriverObservation,
|
||||||
Mode: config.SourceModePoll,
|
Mode: config.SourceModePoll,
|
||||||
Params: map[string]any{
|
Params: map[string]any{
|
||||||
"url": srv.URL,
|
"url": srv.URL,
|
||||||
@@ -41,7 +42,7 @@ func TestObservationSourcePollReturnsNoEventsOn304(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewObservationSource() error = %v", err)
|
t.Fatalf("NewObservationSource() error = %v", err)
|
||||||
}
|
}
|
||||||
if got := src.Kinds(); len(got) != 1 || got[0] != event.Kind("observation") {
|
if got := src.Kinds(); len(got) != 1 || got[0] != event.Kind(standards.KindObservation) {
|
||||||
t.Fatalf("Kinds() = %#v, want [observation]", got)
|
t.Fatalf("Kinds() = %#v, want [observation]", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ func TestObservationSourcePollReturnsNoEventsOn304(t *testing.T) {
|
|||||||
if len(first) != 1 {
|
if len(first) != 1 {
|
||||||
t.Fatalf("first Poll() len = %d, want 1", len(first))
|
t.Fatalf("first Poll() len = %d, want 1", len(first))
|
||||||
}
|
}
|
||||||
if first[0].Kind != event.Kind("observation") {
|
if first[0].Kind != event.Kind(standards.KindObservation) {
|
||||||
t.Fatalf("first Poll() kind = %q", first[0].Kind)
|
t.Fatalf("first Poll() kind = %q", first[0].Kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ type WeatherStoriesSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewWeatherStoriesSource(cfg config.SourceConfig) (*WeatherStoriesSource, error) {
|
func NewWeatherStoriesSource(cfg config.SourceConfig) (*WeatherStoriesSource, error) {
|
||||||
const driver = "nws_weatherstories"
|
hs, err := fksources.NewHTTPSource(DriverWeatherStories, cfg, "application/geo+json, application/json")
|
||||||
|
|
||||||
hs, err := fksources.NewHTTPSource(driver, cfg, "application/geo+json, application/json")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -35,7 +33,7 @@ func NewWeatherStoriesSource(cfg config.SourceConfig) (*WeatherStoriesSource, er
|
|||||||
func (s *WeatherStoriesSource) Name() string { return s.http.Name }
|
func (s *WeatherStoriesSource) Name() string { return s.http.Name }
|
||||||
|
|
||||||
func (s *WeatherStoriesSource) Kinds() []event.Kind {
|
func (s *WeatherStoriesSource) Kinds() []event.Kind {
|
||||||
return []event.Kind{event.Kind("weather_story")}
|
return []event.Kind{event.Kind(standards.KindWeatherStory)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WeatherStoriesSource) Poll(ctx context.Context) ([]event.Event, error) {
|
func (s *WeatherStoriesSource) Poll(ctx context.Context) ([]event.Event, error) {
|
||||||
@@ -61,7 +59,7 @@ func (s *WeatherStoriesSource) Poll(ctx context.Context) ([]event.Event, error)
|
|||||||
eventID := fksources.DefaultEventID("", s.http.Name, effectiveAt, emittedAt)
|
eventID := fksources.DefaultEventID("", s.http.Name, effectiveAt, emittedAt)
|
||||||
|
|
||||||
return fksources.SingleEvent(
|
return fksources.SingleEvent(
|
||||||
event.Kind("weather_story"),
|
event.Kind(standards.KindWeatherStory),
|
||||||
s.http.Name,
|
s.http.Name,
|
||||||
standards.SchemaRawNWSWeatherStoriesV1,
|
standards.SchemaRawNWSWeatherStoriesV1,
|
||||||
eventID,
|
eventID,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestWeatherStoriesSourcePollEmitsExpectedEventAndPrefersLatestUpdateTime(t
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewWeatherStoriesSource() error = %v", err)
|
t.Fatalf("NewWeatherStoriesSource() error = %v", err)
|
||||||
}
|
}
|
||||||
if got := src.Kinds(); len(got) != 1 || got[0] != event.Kind("weather_story") {
|
if got := src.Kinds(); len(got) != 1 || got[0] != event.Kind(standards.KindWeatherStory) {
|
||||||
t.Fatalf("Kinds() = %#v, want [weather_story]", got)
|
t.Fatalf("Kinds() = %#v, want [weather_story]", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ func TestWeatherStoriesSourcePollEmitsExpectedEventAndPrefersLatestUpdateTime(t
|
|||||||
}
|
}
|
||||||
|
|
||||||
got := events[0]
|
got := events[0]
|
||||||
if got.Kind != event.Kind("weather_story") {
|
if got.Kind != event.Kind(standards.KindWeatherStory) {
|
||||||
t.Fatalf("Kind = %q, want weather_story", got.Kind)
|
t.Fatalf("Kind = %q, want weather_story", got.Kind)
|
||||||
}
|
}
|
||||||
if got.Schema != standards.SchemaRawNWSWeatherStoriesV1 {
|
if got.Schema != standards.SchemaRawNWSWeatherStoriesV1 {
|
||||||
@@ -148,7 +148,7 @@ func TestWeatherStoriesSourcePollMetadataDecodeFailureStillEmitsRawEvent(t *test
|
|||||||
func weatherStoriesSourceConfig(url string) config.SourceConfig {
|
func weatherStoriesSourceConfig(url string) config.SourceConfig {
|
||||||
return config.SourceConfig{
|
return config.SourceConfig{
|
||||||
Name: "test-weatherstories-source",
|
Name: "test-weatherstories-source",
|
||||||
Driver: "nws_weatherstories",
|
Driver: DriverWeatherStories,
|
||||||
Mode: config.SourceModePoll,
|
Mode: config.SourceModePoll,
|
||||||
Params: map[string]any{
|
Params: map[string]any{
|
||||||
"url": url,
|
"url": url,
|
||||||
|
|||||||
7
internal/sources/openmeteo/driver.go
Normal file
7
internal/sources/openmeteo/driver.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package openmeteo
|
||||||
|
|
||||||
|
// Source driver strings registered by weatherfeeder for Open-Meteo sources.
|
||||||
|
const (
|
||||||
|
DriverObservation = "openmeteo_observation"
|
||||||
|
DriverForecast = "openmeteo_forecast"
|
||||||
|
)
|
||||||
@@ -19,9 +19,7 @@ type ForecastSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewForecastSource(cfg config.SourceConfig) (*ForecastSource, error) {
|
func NewForecastSource(cfg config.SourceConfig) (*ForecastSource, error) {
|
||||||
const driver = "openmeteo_forecast"
|
hs, err := fksources.NewHTTPSource(DriverForecast, cfg, "application/json")
|
||||||
|
|
||||||
hs, err := fksources.NewHTTPSource(driver, cfg, "application/json")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -31,7 +29,9 @@ func NewForecastSource(cfg config.SourceConfig) (*ForecastSource, error) {
|
|||||||
|
|
||||||
func (s *ForecastSource) Name() string { return s.http.Name }
|
func (s *ForecastSource) Name() string { return s.http.Name }
|
||||||
|
|
||||||
func (s *ForecastSource) Kinds() []event.Kind { return []event.Kind{event.Kind("forecast")} }
|
func (s *ForecastSource) Kinds() []event.Kind {
|
||||||
|
return []event.Kind{event.Kind(standards.KindForecast)}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ForecastSource) Poll(ctx context.Context) ([]event.Event, error) {
|
func (s *ForecastSource) Poll(ctx context.Context) ([]event.Event, error) {
|
||||||
raw, meta, changed, err := s.fetchRaw(ctx)
|
raw, meta, changed, err := s.fetchRaw(ctx)
|
||||||
@@ -55,7 +55,7 @@ func (s *ForecastSource) Poll(ctx context.Context) ([]event.Event, error) {
|
|||||||
eventID := fksources.DefaultEventID("", s.http.Name, effectiveAt, emittedAt)
|
eventID := fksources.DefaultEventID("", s.http.Name, effectiveAt, emittedAt)
|
||||||
|
|
||||||
return fksources.SingleEvent(
|
return fksources.SingleEvent(
|
||||||
event.Kind("forecast"),
|
event.Kind(standards.KindForecast),
|
||||||
s.http.Name,
|
s.http.Name,
|
||||||
standards.SchemaRawOpenMeteoHourlyForecastV1,
|
standards.SchemaRawOpenMeteoHourlyForecastV1,
|
||||||
eventID,
|
eventID,
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ type ObservationSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewObservationSource(cfg config.SourceConfig) (*ObservationSource, error) {
|
func NewObservationSource(cfg config.SourceConfig) (*ObservationSource, error) {
|
||||||
const driver = "openmeteo_observation"
|
hs, err := fksources.NewHTTPSource(DriverObservation, cfg, "application/json")
|
||||||
|
|
||||||
hs, err := fksources.NewHTTPSource(driver, cfg, "application/json")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -31,7 +29,9 @@ func NewObservationSource(cfg config.SourceConfig) (*ObservationSource, error) {
|
|||||||
|
|
||||||
func (s *ObservationSource) Name() string { return s.http.Name }
|
func (s *ObservationSource) Name() string { return s.http.Name }
|
||||||
|
|
||||||
func (s *ObservationSource) Kinds() []event.Kind { return []event.Kind{event.Kind("observation")} }
|
func (s *ObservationSource) Kinds() []event.Kind {
|
||||||
|
return []event.Kind{event.Kind(standards.KindObservation)}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) {
|
func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) {
|
||||||
raw, meta, changed, err := s.fetchRaw(ctx)
|
raw, meta, changed, err := s.fetchRaw(ctx)
|
||||||
@@ -52,7 +52,7 @@ func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) {
|
|||||||
eventID := fksources.DefaultEventID("", s.http.Name, effectiveAt, emittedAt)
|
eventID := fksources.DefaultEventID("", s.http.Name, effectiveAt, emittedAt)
|
||||||
|
|
||||||
return fksources.SingleEvent(
|
return fksources.SingleEvent(
|
||||||
event.Kind("observation"),
|
event.Kind(standards.KindObservation),
|
||||||
s.http.Name,
|
s.http.Name,
|
||||||
standards.SchemaRawOpenMeteoCurrentV1,
|
standards.SchemaRawOpenMeteoCurrentV1,
|
||||||
eventID,
|
eventID,
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/feedkit/config"
|
"gitea.maximumdirect.net/ejr/feedkit/config"
|
||||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestObservationSourceAdvertisesKinds(t *testing.T) {
|
func TestObservationSourceAdvertisesKinds(t *testing.T) {
|
||||||
src, err := NewObservationSource(config.SourceConfig{
|
src, err := NewObservationSource(config.SourceConfig{
|
||||||
Name: "openmeteo-observation-test",
|
Name: "openmeteo-observation-test",
|
||||||
Driver: "openmeteo_observation",
|
Driver: DriverObservation,
|
||||||
Mode: config.SourceModePoll,
|
Mode: config.SourceModePoll,
|
||||||
Params: map[string]any{
|
Params: map[string]any{
|
||||||
"url": "https://example.invalid",
|
"url": "https://example.invalid",
|
||||||
@@ -20,7 +21,7 @@ func TestObservationSourceAdvertisesKinds(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewObservationSource() error = %v", err)
|
t.Fatalf("NewObservationSource() error = %v", err)
|
||||||
}
|
}
|
||||||
if got := src.Kinds(); len(got) != 1 || got[0] != event.Kind("observation") {
|
if got := src.Kinds(); len(got) != 1 || got[0] != event.Kind(standards.KindObservation) {
|
||||||
t.Fatalf("Kinds() = %#v, want [observation]", got)
|
t.Fatalf("Kinds() = %#v, want [observation]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,7 +29,7 @@ func TestObservationSourceAdvertisesKinds(t *testing.T) {
|
|||||||
func TestForecastSourceAdvertisesKinds(t *testing.T) {
|
func TestForecastSourceAdvertisesKinds(t *testing.T) {
|
||||||
src, err := NewForecastSource(config.SourceConfig{
|
src, err := NewForecastSource(config.SourceConfig{
|
||||||
Name: "openmeteo-forecast-test",
|
Name: "openmeteo-forecast-test",
|
||||||
Driver: "openmeteo_forecast",
|
Driver: DriverForecast,
|
||||||
Mode: config.SourceModePoll,
|
Mode: config.SourceModePoll,
|
||||||
Params: map[string]any{
|
Params: map[string]any{
|
||||||
"url": "https://example.invalid",
|
"url": "https://example.invalid",
|
||||||
@@ -38,7 +39,7 @@ func TestForecastSourceAdvertisesKinds(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewForecastSource() error = %v", err)
|
t.Fatalf("NewForecastSource() error = %v", err)
|
||||||
}
|
}
|
||||||
if got := src.Kinds(); len(got) != 1 || got[0] != event.Kind("forecast") {
|
if got := src.Kinds(); len(got) != 1 || got[0] != event.Kind(standards.KindForecast) {
|
||||||
t.Fatalf("Kinds() = %#v, want [forecast]", got)
|
t.Fatalf("Kinds() = %#v, want [forecast]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
internal/sources/openweather/driver.go
Normal file
6
internal/sources/openweather/driver.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package openweather
|
||||||
|
|
||||||
|
// Source driver strings registered by weatherfeeder for OpenWeather sources.
|
||||||
|
const (
|
||||||
|
DriverObservation = "openweather_observation"
|
||||||
|
)
|
||||||
@@ -19,9 +19,7 @@ type ObservationSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewObservationSource(cfg config.SourceConfig) (*ObservationSource, error) {
|
func NewObservationSource(cfg config.SourceConfig) (*ObservationSource, error) {
|
||||||
const driver = "openweather_observation"
|
hs, err := fksources.NewHTTPSource(DriverObservation, cfg, "application/json")
|
||||||
|
|
||||||
hs, err := fksources.NewHTTPSource(driver, cfg, "application/json")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -35,7 +33,9 @@ func NewObservationSource(cfg config.SourceConfig) (*ObservationSource, error) {
|
|||||||
|
|
||||||
func (s *ObservationSource) Name() string { return s.http.Name }
|
func (s *ObservationSource) Name() string { return s.http.Name }
|
||||||
|
|
||||||
func (s *ObservationSource) Kinds() []event.Kind { return []event.Kind{event.Kind("observation")} }
|
func (s *ObservationSource) Kinds() []event.Kind {
|
||||||
|
return []event.Kind{event.Kind(standards.KindObservation)}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) {
|
func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) {
|
||||||
if err := owcommon.RequireMetricUnits(s.http.URL); err != nil {
|
if err := owcommon.RequireMetricUnits(s.http.URL); err != nil {
|
||||||
@@ -60,7 +60,7 @@ func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) {
|
|||||||
eventID := fksources.DefaultEventID("", s.http.Name, effectiveAt, emittedAt)
|
eventID := fksources.DefaultEventID("", s.http.Name, effectiveAt, emittedAt)
|
||||||
|
|
||||||
return fksources.SingleEvent(
|
return fksources.SingleEvent(
|
||||||
event.Kind("observation"),
|
event.Kind(standards.KindObservation),
|
||||||
s.http.Name,
|
s.http.Name,
|
||||||
standards.SchemaRawOpenWeatherCurrentV1,
|
standards.SchemaRawOpenWeatherCurrentV1,
|
||||||
eventID,
|
eventID,
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/feedkit/config"
|
"gitea.maximumdirect.net/ejr/feedkit/config"
|
||||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestObservationSourceAdvertisesKinds(t *testing.T) {
|
func TestObservationSourceAdvertisesKinds(t *testing.T) {
|
||||||
src, err := NewObservationSource(config.SourceConfig{
|
src, err := NewObservationSource(config.SourceConfig{
|
||||||
Name: "openweather-observation-test",
|
Name: "openweather-observation-test",
|
||||||
Driver: "openweather_observation",
|
Driver: DriverObservation,
|
||||||
Mode: config.SourceModePoll,
|
Mode: config.SourceModePoll,
|
||||||
Params: map[string]any{
|
Params: map[string]any{
|
||||||
"url": "https://example.invalid?units=metric",
|
"url": "https://example.invalid?units=metric",
|
||||||
@@ -20,7 +21,7 @@ func TestObservationSourceAdvertisesKinds(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewObservationSource() error = %v", err)
|
t.Fatalf("NewObservationSource() error = %v", err)
|
||||||
}
|
}
|
||||||
if got := src.Kinds(); len(got) != 1 || got[0] != event.Kind("observation") {
|
if got := src.Kinds(); len(got) != 1 || got[0] != event.Kind(standards.KindObservation) {
|
||||||
t.Fatalf("Kinds() = %#v, want [observation]", got)
|
t.Fatalf("Kinds() = %#v, want [observation]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,11 @@ import (
|
|||||||
fksources "gitea.maximumdirect.net/ejr/feedkit/sources"
|
fksources "gitea.maximumdirect.net/ejr/feedkit/sources"
|
||||||
"gitea.maximumdirect.net/ejr/feedkit/transport"
|
"gitea.maximumdirect.net/ejr/feedkit/transport"
|
||||||
spcprovider "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/spc"
|
spcprovider "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/spc"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/internal/httpconfig"
|
||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
driverConvectiveOutlook = "spc_convective_outlook"
|
|
||||||
|
|
||||||
acceptGeoJSON = "application/geo+json, application/json"
|
acceptGeoJSON = "application/geo+json, application/json"
|
||||||
acceptDiscussion = "text/html, application/xhtml+xml"
|
acceptDiscussion = "text/html, application/xhtml+xml"
|
||||||
acceptRSS = "application/rss+xml, application/xml, text/xml"
|
acceptRSS = "application/rss+xml, application/xml, text/xml"
|
||||||
@@ -56,53 +55,27 @@ type ConvectiveOutlookSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewConvectiveOutlookSource(cfg config.SourceConfig) (*ConvectiveOutlookSource, error) {
|
func NewConvectiveOutlookSource(cfg config.SourceConfig) (*ConvectiveOutlookSource, error) {
|
||||||
name := strings.TrimSpace(cfg.Name)
|
httpSettings, err := httpconfig.Parse(DriverConvectiveOutlook, cfg)
|
||||||
if name == "" {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: name is required", driverConvectiveOutlook)
|
return nil, err
|
||||||
}
|
|
||||||
if cfg.Params == nil {
|
|
||||||
return nil, fmt.Errorf("%s %q: params are required", driverConvectiveOutlook, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
userAgent, ok := cfg.ParamString("user_agent", "userAgent")
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("%s %q: params.user_agent is required", driverConvectiveOutlook, name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
latitude, err := requireFloatParam(cfg, "latitude")
|
latitude, err := requireFloatParam(cfg, "latitude")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s %q: %w", driverConvectiveOutlook, name, err)
|
return nil, fmt.Errorf("%s %q: %w", DriverConvectiveOutlook, httpSettings.Name, err)
|
||||||
}
|
}
|
||||||
longitude, err := requireFloatParam(cfg, "longitude")
|
longitude, err := requireFloatParam(cfg, "longitude")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s %q: %w", driverConvectiveOutlook, name, err)
|
return nil, fmt.Errorf("%s %q: %w", DriverConvectiveOutlook, httpSettings.Name, err)
|
||||||
}
|
|
||||||
|
|
||||||
timeout := transport.DefaultHTTPTimeout
|
|
||||||
if _, exists := cfg.Params["http_timeout"]; exists {
|
|
||||||
var ok bool
|
|
||||||
timeout, ok = cfg.ParamDuration("http_timeout")
|
|
||||||
if !ok || timeout <= 0 {
|
|
||||||
return nil, fmt.Errorf("source %q: params.http_timeout must be a positive duration", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyLimit := transport.DefaultHTTPResponseBodyLimitBytes
|
|
||||||
if _, exists := cfg.Params["http_response_body_limit_bytes"]; exists {
|
|
||||||
rawLimit, ok := cfg.ParamInt("http_response_body_limit_bytes")
|
|
||||||
if !ok || rawLimit <= 0 {
|
|
||||||
return nil, fmt.Errorf("source %q: params.http_response_body_limit_bytes must be a positive integer", name)
|
|
||||||
}
|
|
||||||
bodyLimit = int64(rawLimit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
geoJSONProducts, err := configuredGeoJSONProducts(cfg)
|
geoJSONProducts, err := configuredGeoJSONProducts(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s %q: %w", driverConvectiveOutlook, name, err)
|
return nil, fmt.Errorf("%s %q: %w", DriverConvectiveOutlook, httpSettings.Name, err)
|
||||||
}
|
}
|
||||||
discussions, err := configuredDiscussionProducts(cfg)
|
discussions, err := configuredDiscussionProducts(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s %q: %w", driverConvectiveOutlook, name, err)
|
return nil, fmt.Errorf("%s %q: %w", DriverConvectiveOutlook, httpSettings.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rssURL := ""
|
rssURL := ""
|
||||||
@@ -114,14 +87,14 @@ func NewConvectiveOutlookSource(cfg config.SourceConfig) (*ConvectiveOutlookSour
|
|||||||
locationName, _ := cfg.ParamString("location_name", "locationName")
|
locationName, _ := cfg.ParamString("location_name", "locationName")
|
||||||
|
|
||||||
return &ConvectiveOutlookSource{
|
return &ConvectiveOutlookSource{
|
||||||
name: name,
|
name: httpSettings.Name,
|
||||||
userAgent: userAgent,
|
userAgent: httpSettings.UserAgent,
|
||||||
locationID: locationID,
|
locationID: locationID,
|
||||||
locationName: locationName,
|
locationName: locationName,
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
client: transport.NewHTTPClient(timeout),
|
client: transport.NewHTTPClient(httpSettings.Timeout),
|
||||||
bodyLimit: bodyLimit,
|
bodyLimit: httpSettings.BodyLimitBytes,
|
||||||
geoJSONProducts: geoJSONProducts,
|
geoJSONProducts: geoJSONProducts,
|
||||||
discussions: discussions,
|
discussions: discussions,
|
||||||
rssURL: rssURL,
|
rssURL: rssURL,
|
||||||
@@ -131,7 +104,7 @@ func NewConvectiveOutlookSource(cfg config.SourceConfig) (*ConvectiveOutlookSour
|
|||||||
func (s *ConvectiveOutlookSource) Name() string { return s.name }
|
func (s *ConvectiveOutlookSource) Name() string { return s.name }
|
||||||
|
|
||||||
func (s *ConvectiveOutlookSource) Kinds() []event.Kind {
|
func (s *ConvectiveOutlookSource) Kinds() []event.Kind {
|
||||||
return []event.Kind{event.Kind("outlook")}
|
return []event.Kind{event.Kind(standards.KindOutlook)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ConvectiveOutlookSource) Poll(ctx context.Context) ([]event.Event, error) {
|
func (s *ConvectiveOutlookSource) Poll(ctx context.Context) ([]event.Event, error) {
|
||||||
@@ -221,7 +194,7 @@ func (s *ConvectiveOutlookSource) Poll(ctx context.Context) ([]event.Event, erro
|
|||||||
eventID := fksources.DefaultEventID("", s.name, &effectiveAt, emittedAt)
|
eventID := fksources.DefaultEventID("", s.name, &effectiveAt, emittedAt)
|
||||||
|
|
||||||
return fksources.SingleEvent(
|
return fksources.SingleEvent(
|
||||||
event.Kind("outlook"),
|
event.Kind(standards.KindOutlook),
|
||||||
s.name,
|
s.name,
|
||||||
standards.SchemaRawSPCConvectiveOutlookV1,
|
standards.SchemaRawSPCConvectiveOutlookV1,
|
||||||
eventID,
|
eventID,
|
||||||
@@ -377,11 +350,7 @@ func latestIssueTime(raw []byte) time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func discussionUpdatedTime(rawHTML string) time.Time {
|
func discussionUpdatedTime(rawHTML string) time.Time {
|
||||||
text, err := spcprovider.ExtractProductText(rawHTML)
|
t := spcprovider.ParsePageUpdatedTimestamp(rawHTML)
|
||||||
if err != nil {
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
t := spcprovider.ParseUpdatedTimestamp(text)
|
|
||||||
if t == nil {
|
if t == nil {
|
||||||
return time.Time{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,7 +20,7 @@ func TestConvectiveOutlookSourceKinds(t *testing.T) {
|
|||||||
t.Fatalf("NewConvectiveOutlookSource() error = %v", err)
|
t.Fatalf("NewConvectiveOutlookSource() error = %v", err)
|
||||||
}
|
}
|
||||||
got := src.Kinds()
|
got := src.Kinds()
|
||||||
if len(got) != 1 || got[0] != event.Kind("outlook") {
|
if len(got) != 1 || got[0] != event.Kind(standards.KindOutlook) {
|
||||||
t.Fatalf("Kinds() = %#v, want [outlook]", got)
|
t.Fatalf("Kinds() = %#v, want [outlook]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +55,7 @@ func TestConvectiveOutlookSourcePollEmitsRawBundle(t *testing.T) {
|
|||||||
t.Fatalf("Poll() returned %d events, want 1", len(events))
|
t.Fatalf("Poll() returned %d events, want 1", len(events))
|
||||||
}
|
}
|
||||||
got := events[0]
|
got := events[0]
|
||||||
if got.Kind != event.Kind("outlook") {
|
if got.Kind != event.Kind(standards.KindOutlook) {
|
||||||
t.Fatalf("Kind = %q, want outlook", got.Kind)
|
t.Fatalf("Kind = %q, want outlook", got.Kind)
|
||||||
}
|
}
|
||||||
if got.Schema != standards.SchemaRawSPCConvectiveOutlookV1 {
|
if got.Schema != standards.SchemaRawSPCConvectiveOutlookV1 {
|
||||||
@@ -78,8 +76,8 @@ func TestConvectiveOutlookSourcePollEmitsRawBundle(t *testing.T) {
|
|||||||
if bundle.Latitude != 38.6239 || bundle.Longitude != -90.3571 {
|
if bundle.Latitude != 38.6239 || bundle.Longitude != -90.3571 {
|
||||||
t.Fatalf("coordinates = %v,%v", bundle.Latitude, bundle.Longitude)
|
t.Fatalf("coordinates = %v,%v", bundle.Latitude, bundle.Longitude)
|
||||||
}
|
}
|
||||||
if len(bundle.Products) != 12 {
|
if len(bundle.Products) != 9 {
|
||||||
t.Fatalf("Products length = %d, want 12", len(bundle.Products))
|
t.Fatalf("Products length = %d, want 9", len(bundle.Products))
|
||||||
}
|
}
|
||||||
if len(bundle.Discussions) != 3 {
|
if len(bundle.Discussions) != 3 {
|
||||||
t.Fatalf("Discussions length = %d, want 3", len(bundle.Discussions))
|
t.Fatalf("Discussions length = %d, want 3", len(bundle.Discussions))
|
||||||
@@ -277,7 +275,7 @@ func convectiveOutlookConfig(extra map[string]any) config.SourceConfig {
|
|||||||
}
|
}
|
||||||
return config.SourceConfig{
|
return config.SourceConfig{
|
||||||
Name: "spc-test",
|
Name: "spc-test",
|
||||||
Driver: driverConvectiveOutlook,
|
Driver: DriverConvectiveOutlook,
|
||||||
Mode: config.SourceModePoll,
|
Mode: config.SourceModePoll,
|
||||||
Params: params,
|
Params: params,
|
||||||
}
|
}
|
||||||
@@ -312,7 +310,7 @@ func geoJSONFixtureForProduct(t *testing.T, key string, blankIssueISO bool) []by
|
|||||||
case strings.HasPrefix(key, "day2_"):
|
case strings.HasPrefix(key, "day2_"):
|
||||||
name = "day2_torn.geojson"
|
name = "day2_torn.geojson"
|
||||||
case strings.HasPrefix(key, "day3_"):
|
case strings.HasPrefix(key, "day3_"):
|
||||||
name = "day3_wind.geojson"
|
name = "day3_cat.geojson"
|
||||||
default:
|
default:
|
||||||
t.Fatalf("unknown product key %q", key)
|
t.Fatalf("unknown product key %q", key)
|
||||||
}
|
}
|
||||||
@@ -340,16 +338,6 @@ func discussionFixtureForProduct(t *testing.T, key string) []byte {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readSPCTestFixture(t *testing.T, name string) []byte {
|
|
||||||
t.Helper()
|
|
||||||
path := filepath.Join("..", "..", "providers", "spc", "testdata", name)
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read fixture %s: %v", path, err)
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
|
|
||||||
const testRSS = `<?xml version="1.0"?>
|
const testRSS = `<?xml version="1.0"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
|
|||||||
6
internal/sources/spc/driver.go
Normal file
6
internal/sources/spc/driver.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package spc
|
||||||
|
|
||||||
|
// Source driver strings registered by weatherfeeder for SPC sources.
|
||||||
|
const (
|
||||||
|
DriverConvectiveOutlook = "spc_convective_outlook"
|
||||||
|
)
|
||||||
17
internal/sources/spc/fixture_test.go
Normal file
17
internal/sources/spc/fixture_test.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package spc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readSPCTestFixture(t *testing.T, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join("..", "..", "providers", "spc", "testdata", name)
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
37
model/docs_test.go
Normal file
37
model/docs_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDocumentedConsumerModelTypes(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../docs/consumers/pkg-model.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(pkg-model.md) error = %v", err)
|
||||||
|
}
|
||||||
|
doc := string(raw)
|
||||||
|
|
||||||
|
required := []string{
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model",
|
||||||
|
"WeatherObservation",
|
||||||
|
"WeatherForecastRun",
|
||||||
|
"WeatherForecastPeriod",
|
||||||
|
"WeatherForecastDiscussion",
|
||||||
|
"WeatherForecastDiscussionSection",
|
||||||
|
"WeatherStoryRun",
|
||||||
|
"WeatherStory",
|
||||||
|
"WeatherAlertRun",
|
||||||
|
"WeatherAlert",
|
||||||
|
"WeatherAlertReference",
|
||||||
|
"WeatherOutlookRun",
|
||||||
|
"WeatherOutlook",
|
||||||
|
"WMOCode",
|
||||||
|
}
|
||||||
|
for _, want := range required {
|
||||||
|
if !strings.Contains(doc, want) {
|
||||||
|
t.Fatalf("docs/consumers/pkg-model.md missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
standards/docs_test.go
Normal file
101
standards/docs_test.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package standards
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDocumentedEventSchemas(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../docs/integrations/events.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(events.md) error = %v", err)
|
||||||
|
}
|
||||||
|
doc := string(raw)
|
||||||
|
|
||||||
|
for _, schema := range schemaConstants(t, false) {
|
||||||
|
if !strings.Contains(doc, schema) {
|
||||||
|
t.Fatalf("docs/integrations/events.md missing schema %q", schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentedConsumerStandardsConstants(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../docs/consumers/pkg-standards.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(pkg-standards.md) error = %v", err)
|
||||||
|
}
|
||||||
|
doc := string(raw)
|
||||||
|
|
||||||
|
for _, schema := range schemaConstants(t, true) {
|
||||||
|
if !strings.Contains(doc, schema) {
|
||||||
|
t.Fatalf("docs/consumers/pkg-standards.md missing schema %q", schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, kind := range kindConstants(t) {
|
||||||
|
if !strings.Contains(doc, kind) {
|
||||||
|
t.Fatalf("docs/consumers/pkg-standards.md missing kind %q", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemaConstants(t *testing.T, includeNonCurrent bool) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return stringConstantsFromFile(t, "schema.go", "Schema", func(name string) bool {
|
||||||
|
return !includeNonCurrent && schemaConstantNotInCurrentContract(name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func kindConstants(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return stringConstantsFromFile(t, "kind.go", "Kind", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringConstantsFromFile(t *testing.T, path string, prefix string, skip func(string) bool) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFile(%s) error = %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
ast.Inspect(file, func(n ast.Node) bool {
|
||||||
|
valueSpec, ok := n.(*ast.ValueSpec)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for i, name := range valueSpec.Names {
|
||||||
|
if !strings.HasPrefix(name.Name, prefix) || (skip != nil && skip(name.Name)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i >= len(valueSpec.Values) {
|
||||||
|
t.Fatalf("constant %s has no explicit value", name.Name)
|
||||||
|
}
|
||||||
|
lit, ok := valueSpec.Values[i].(*ast.BasicLit)
|
||||||
|
if !ok || lit.Kind != token.STRING {
|
||||||
|
t.Fatalf("constant %s is not a string literal", name.Name)
|
||||||
|
}
|
||||||
|
value, err := strconv.Unquote(lit.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("constant %s value is not a quoted string: %v", name.Name, err)
|
||||||
|
}
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if len(out) == 0 {
|
||||||
|
t.Fatalf("no %s constants found in %s", prefix, path)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemaConstantNotInCurrentContract(name string) bool {
|
||||||
|
return name == "SchemaRawOpenWeatherHourlyForecastV1"
|
||||||
|
}
|
||||||
11
standards/kind.go
Normal file
11
standards/kind.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package standards
|
||||||
|
|
||||||
|
// Event kind strings used by weatherfeeder events and routing policy.
|
||||||
|
const (
|
||||||
|
KindObservation = "observation"
|
||||||
|
KindForecast = "forecast"
|
||||||
|
KindForecastDiscussion = "forecast_discussion"
|
||||||
|
KindWeatherStory = "weather_story"
|
||||||
|
KindAlert = "alert"
|
||||||
|
KindOutlook = "outlook"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user