aboutsummaryrefslogtreecommitdiff
path: root/internal/imgw
diff options
context:
space:
mode:
Diffstat (limited to 'internal/imgw')
-rw-r--r--internal/imgw/imgw.go156
-rw-r--r--internal/imgw/imgw_test.go190
-rw-r--r--internal/imgw/testdata/gugik_abroad.json1
-rw-r--r--internal/imgw/testdata/warnings.json1
4 files changed, 348 insertions, 0 deletions
diff --git a/internal/imgw/imgw.go b/internal/imgw/imgw.go
new file mode 100644
index 0000000..48ffe19
--- /dev/null
+++ b/internal/imgw/imgw.go
@@ -0,0 +1,156 @@
+// Package imgw fetches official Polish meteorological warnings and resolves a
+// point to the powiat code those warnings are tagged with.
+//
+// Both services are public and need no key.
+package imgw
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+)
+
+// Endpoints are variables so tests can serve recorded fixtures locally.
+var (
+ warningsURL = "https://danepubliczne.imgw.pl/api/data/warningsmeteo"
+ gugikURL = "https://services.gugik.gov.pl/uug/"
+)
+
+// now is the clock, replaceable in tests: whether a warning has expired depends
+// on it.
+var now = time.Now
+
+// Timeout bounds every request.
+var Timeout = 15 * time.Second
+
+// Status is the outcome of resolving a point to a powiat.
+type Status int
+
+const (
+ // StatusOK means the point resolved to a powiat code.
+ StatusOK Status = iota
+ // StatusOutside means GUGiK answered but knows no address there. It covers
+ // Poland only, so the point is abroad.
+ StatusOutside
+ // StatusError means the service could not be asked.
+ //
+ // This must never be conflated with StatusOutside: one means "no warnings
+ // apply here", the other "I do not know whether any apply".
+ StatusError
+)
+
+// Warning is one IMGW warning in force.
+type Warning struct {
+ Event string `json:"nazwa_zdarzenia"`
+ Level string `json:"stopien"`
+ Probability string `json:"prawdopodobienstwo"`
+ From string `json:"obowiazuje_od"`
+ To string `json:"obowiazuje_do"`
+ Text string `json:"tresc"`
+ Teryt []any `json:"teryt"`
+}
+
+func fetch(rawURL string, params url.Values, into any) error {
+ host := ""
+ if u, err := url.Parse(rawURL); err == nil {
+ host = u.Host
+ }
+ full := rawURL
+ if len(params) > 0 {
+ full += "?" + params.Encode()
+ }
+ req, err := http.NewRequest("GET", full, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("User-Agent", "prognosis/1.0")
+ resp, err := (&http.Client{Timeout: Timeout}).Do(req)
+ if err != nil {
+ return fmt.Errorf("cannot reach %s: %w", host, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("%s returned HTTP %d", host, resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(body, into)
+}
+
+type gugikResponse struct {
+ Results map[string]struct {
+ Teryt string `json:"teryt"`
+ } `json:"results"`
+}
+
+// Powiat resolves a point to its 4-digit TERYT powiat code via GUGiK.
+//
+// IMGW tags every warning with the powiat codes it covers, so this is what
+// makes "warnings for my area" mean this area rather than the whole country.
+func Powiat(lat, lon float64) (string, Status, error) {
+ var r gugikResponse
+ err := fetch(gugikURL, url.Values{
+ "request": {"GetAddressReverse"},
+ "location": {fmt.Sprintf("POINT(%.6f %.6f)", lon, lat)},
+ "srid": {"4326"},
+ // The default 100 m radius finds nothing in the mountains or deep
+ // countryside, which looks identical to being abroad and would suppress
+ // real warnings. GUGiK clamps this to its own 5 km maximum.
+ "radius": {"10000"},
+ }, &r)
+ if err != nil {
+ return "", StatusError, err
+ }
+ for _, entry := range r.Results {
+ if len(entry.Teryt) >= 4 {
+ return entry.Teryt[:4], StatusOK, nil
+ }
+ }
+ return "", StatusOutside, nil
+}
+
+// Warnings returns the warnings in force for a powiat.
+func Warnings(powiat string) ([]Warning, error) {
+ var all []Warning
+ if err := fetch(warningsURL, nil, &all); err != nil {
+ return nil, err
+ }
+ cut := now()
+ var live []Warning
+ for _, w := range all {
+ if !w.covers(powiat) {
+ continue
+ }
+ // An expired warning is dropped; one whose date will not parse is kept,
+ // because showing a stale warning beats hiding a live one.
+ if to, err := time.ParseInLocation("2006-01-02 15:04:05", w.To, time.Local); err == nil {
+ if to.Before(cut) {
+ continue
+ }
+ }
+ live = append(live, w)
+ }
+ return live, nil
+}
+
+func (w Warning) covers(powiat string) bool {
+ for _, a := range w.Teryt {
+ switch v := a.(type) {
+ case string:
+ if v == powiat {
+ return true
+ }
+ case float64:
+ if strconv.Itoa(int(v)) == powiat {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/internal/imgw/imgw_test.go b/internal/imgw/imgw_test.go
new file mode 100644
index 0000000..ffc678e
--- /dev/null
+++ b/internal/imgw/imgw_test.go
@@ -0,0 +1,190 @@
+package imgw
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func serve(t *testing.T, fixture string, gotQuery *string) *httptest.Server {
+ t.Helper()
+ body, err := os.ReadFile(filepath.Join("testdata", fixture))
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if gotQuery != nil {
+ *gotQuery = r.URL.RawQuery
+ }
+ w.Write(body)
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func serveText(t *testing.T, body string) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(body))
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func pinClock(t *testing.T, at time.Time) {
+ t.Helper()
+ old := now
+ now = func() time.Time { return at }
+ t.Cleanup(func() { now = old })
+}
+
+// A point inside Poland resolves to the first four digits of its TERYT code.
+func TestPowiatTruncatesTerytToFourDigits(t *testing.T) {
+ srv := serve(t, "gugik_krakow.json", nil)
+ gugikURL = srv.URL
+ code, status, err := Powiat(50.0617, 19.9373)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status != StatusOK {
+ t.Fatalf("status = %v, want StatusOK", status)
+ }
+ if code != "1815" {
+ t.Fatalf("code = %q, want 1815 (first four digits of 126101)", code)
+ }
+}
+
+// GUGiK answers a foreign point with HTTP 200 and no results. That is a real
+// answer -- "not in Poland" -- and must never be confused with a failure, which
+// is the difference between "no warnings apply" and "I do not know".
+func TestPowiatOutsidePolandIsAnAnswerNotAnError(t *testing.T) {
+ srv := serve(t, "gugik_abroad.json", nil)
+ gugikURL = srv.URL
+ code, status, err := Powiat(52.52, 13.40)
+ if err != nil {
+ t.Fatalf("a valid 'no results' response must not be an error: %v", err)
+ }
+ if status != StatusOutside {
+ t.Fatalf("status = %v, want StatusOutside", status)
+ }
+ if code != "" {
+ t.Fatalf("code = %q, want empty", code)
+ }
+}
+
+func TestPowiatUnreachableIsStatusError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "boom", http.StatusBadGateway)
+ }))
+ srv.Close() // closed: the request cannot connect at all
+ gugikURL = srv.URL
+ _, status, err := Powiat(50, 21)
+ if status != StatusError {
+ t.Fatalf("status = %v, want StatusError", status)
+ }
+ if err == nil {
+ t.Fatal("expected an error describing the failure")
+ }
+}
+
+// The default 100 m radius finds nothing in the mountains, which is
+// indistinguishable from being abroad and would suppress real warnings.
+func TestPowiatAsksForAWideRadius(t *testing.T) {
+ var query string
+ srv := serve(t, "gugik_krakow.json", &query)
+ gugikURL = srv.URL
+ if _, _, err := Powiat(50, 21); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(query, "radius=10000") {
+ t.Fatalf("query %q must ask for a wide radius", query)
+ }
+}
+
+func TestWarningsKeepOnlyThisPowiat(t *testing.T) {
+ pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local)) // nothing expired
+ body := `[
+ {"nazwa_zdarzenia":"Upal","stopien":"1","obowiazuje_do":"2030-01-01 00:00:00","teryt":["1815","1234"]},
+ {"nazwa_zdarzenia":"Burze","stopien":"2","obowiazuje_do":"2030-01-01 00:00:00","teryt":["2207"]}
+ ]`
+ srv := serveText(t, body)
+ warningsURL = srv.URL
+
+ live, err := Warnings("1815")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(live) != 1 || live[0].Event != "Upal" {
+ t.Fatalf("got %+v, want only the warning covering 1815", live)
+ }
+}
+
+func TestWarningsDropExpiredButKeepUnparseableDates(t *testing.T) {
+ pinClock(t, time.Date(2026, 8, 10, 12, 0, 0, 0, time.Local))
+ body := `[
+ {"nazwa_zdarzenia":"Wczorajsze","stopien":"1","obowiazuje_do":"2026-08-09 23:00:00","teryt":["1815"]},
+ {"nazwa_zdarzenia":"Trwajace","stopien":"1","obowiazuje_do":"2026-08-10 20:00:00","teryt":["1815"]},
+ {"nazwa_zdarzenia":"Bezdaty","stopien":"1","obowiazuje_do":"","teryt":["1815"]}
+ ]`
+ srv := serveText(t, body)
+ warningsURL = srv.URL
+
+ live, err := Warnings("1815")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var names []string
+ for _, w := range live {
+ names = append(names, w.Event)
+ }
+ got := strings.Join(names, ",")
+ // Unparseable is kept: showing a stale warning beats hiding a live one.
+ if got != "Trwajace,Bezdaty" {
+ t.Fatalf("got %q, want \"Trwajace,Bezdaty\"", got)
+ }
+}
+
+// IMGW has been seen to encode TERYT codes as numbers as well as strings.
+func TestWarningsMatchNumericTerytCodes(t *testing.T) {
+ pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local))
+ srv := serveText(t, `[{"nazwa_zdarzenia":"X","obowiazuje_do":"2030-01-01 00:00:00","teryt":[1815]}]`)
+ warningsURL = srv.URL
+ live, err := Warnings("1815")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(live) != 1 {
+ t.Fatalf("a numeric teryt entry must still match, got %d warnings", len(live))
+ }
+}
+
+func TestWarningsUnreachableIsAnError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+ srv.Close()
+ warningsURL = srv.URL
+ if _, err := Warnings("1815"); err == nil {
+ t.Fatal("expected an error: the caller must be able to say 'could not check'")
+ }
+}
+
+// The recorded national feed must parse, and must not match a made-up powiat.
+func TestWarningsParseTheRecordedFeed(t *testing.T) {
+ pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local))
+ srv := serve(t, "warnings.json", nil)
+ warningsURL = srv.URL
+
+ if _, err := Warnings("1815"); err != nil {
+ t.Fatalf("the recorded feed must parse: %v", err)
+ }
+ none, err := Warnings("9999")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(none) != 0 {
+ t.Fatalf("powiat 9999 does not exist but matched %d warnings", len(none))
+ }
+}
diff --git a/internal/imgw/testdata/gugik_abroad.json b/internal/imgw/testdata/gugik_abroad.json
new file mode 100644
index 0000000..f6e58d8
--- /dev/null
+++ b/internal/imgw/testdata/gugik_abroad.json
@@ -0,0 +1 @@
+{"type":"address","max results limit":1,"radius":5000,"max polygon area":null,"returned objects":0,"results":null,"request time":0.00072391430536905923} \ No newline at end of file
diff --git a/internal/imgw/testdata/warnings.json b/internal/imgw/testdata/warnings.json
new file mode 100644
index 0000000..2f6b37f
--- /dev/null
+++ b/internal/imgw/testdata/warnings.json
@@ -0,0 +1 @@
+[{"id":"Wr20260810041817563","nazwa_zdarzenia":"Burze","stopien":"2","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-11 00:00:00","obowiazuje_od":"2026-08-10 17:00:00","opublikowano":"2026-08-10 06:18:00","tresc":"Prognozowane s\u0105 miejscami burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do 25 mm oraz porywy wiatru do 85 km\/h, a punktowo mo\u017cliwe porywy do oko\u0142o 100 km\/h. Lokalnie grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["2209","2210","2216","2808","2810","1462","2012","2062","2063","1427","2004","2006","2007","2813","2861","0406","0408","0462","1413","1415","1419","2806","2807","2809","2811","0402","1411","2207","2802","2818","2819","2862","0412","0417","1420","1422","1437","1461","2812","2814","2815","2816","1402","0405","2801","2803","2804","2805","2817"]},{"id":"Wr20260810041839823","nazwa_zdarzenia":"Burze","stopien":"1","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-10 22:00:00","obowiazuje_od":"2026-08-10 17:00:00","opublikowano":"2026-08-10 06:18:00","tresc":"Prognozowane s\u0105 lokalne burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do 25 mm oraz porywy wiatru do 85 km\/h. Mo\u017cliiwy grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["3028","0401","0404","0403","0407","0409","2205","2206","2213","2214","3023","3025","3026","3030","3012","3020","3064","3004","0410","0411","0413","0414","2261","2262","2264","3006","3003","3010","3021","3031","3009","2203","3027","0213","0461","0463","0464","2204","3007","3011","3013","3016","3061","3062","3063","2202","3001","0415","0416","0418","0419","3017","3018","3019","3022"]},{"id":"Wr20260810041856167","nazwa_zdarzenia":"Burze","stopien":"1","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-11 00:00:00","obowiazuje_od":"2026-08-10 18:00:00","opublikowano":"2026-08-10 06:18:00","tresc":"Prognozowane s\u0105 lokalne burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do oko\u0142o 20 mm oraz porywy wiatru do 80 km\/h. Mo\u017cliwy grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["1003","1004","1011","1013","1014","1015","1418","1421","1424","1428","1465","1005","1061","1062","1063","1404","1432","1434","1435","1438","1001","1016","1019","1020","1021","1002","1006","1007","1008","1010","1405","1406","1408","1414"]},{"id":"Wr20260810041907317","nazwa_zdarzenia":"Burze","stopien":"1","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-11 03:00:00","obowiazuje_od":"2026-08-10 19:00:00","opublikowano":"2026-08-10 06:19:00","tresc":"Prognozowane s\u0105 lokalne burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do oko\u0142o 20 mm oraz porywy wiatru do 80 km\/h. Mo\u017cliwy grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["1401","1407","2001","2005","1423","1426","1430","1436","0613","1433","1463","2014","2010","2013","2061","1412","1417","2009","2011","0661","1403","1410","1416","0601","0608","0611","0616","0614","0615","1425","1429","1464","2002","2003","2008"]},{"id":"Sk20260808093414110","nazwa_zdarzenia":"Upa\u0142","stopien":"2","prawdopodobienstwo":"80","obowiazuje_do":"2026-08-10 18:00:00","obowiazuje_od":"2026-08-09 13:00:00","opublikowano":"2026-08-08 11:34:00","tresc":"Prognozuje si\u0119 upa\u0142y. Temperatura maksymalna niedziel\u0119 09.08 od 29\u00b0C do 31\u00b0C, w poniedzia\u0142ek 10.08 od 30\u00b0C do 33\u00b0C. Temperatura minimalna w nocy od 17\u00b0C do 19\u00b0C.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["0812","3210","0211","0201","0802","0803","0804","0805","0807","0808","0809","0810","0811","0862","3212","0204","0264","0203","0801","0209","0216","0218","0220","0861","3206","0222","0223","0225","0262"]},{"id":"Sk20260809095307175","nazwa_zdarzenia":"Upa\u0142","stopien":"1","prawdopodobienstwo":"85","obowiazuje_do":"2026-08-10 20:00:00","obowiazuje_od":"2026-08-10 11:00:00","opublikowano":"2026-08-09 11:53:00","tresc":"Prognozuje si\u0119 upa\u0142. Temperatura maksymalna wyniesie od 30\u00b0C do 33\u00b0C.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["1808","1418","2607","2609","2610","2611","2612","0617","1002","1206","1409","1405","1410","1430","1438","1603","1818","2470","2608","2613","1412","2010","1602","1604","1605","1608","2468","2469","2471","2472","2473","2474","2475","0214","1425","1201","0609","1812","1864","2416","1062","0614","2406","1203","1204","1205","1207","1208","1209","1210","1212","1215","1216","1218","1219","1261","1262","1263","1601","1606","1607","1802","1803","1804","3020","3027","1005","1006","1007","0610","1805","1806","1807","1809","1810","1811","1813","1815","1816","1819","1820","1861","1862","1863","2401","2402","2403","2404","2405","2407","2408","2409","2410","2411","2412","2413","2414","2415","2417","2461","2462","1008","1009","1010","1011","1012","1013","1014","1015","3017","3018","0620","2478","2601","2606","1202","1814","1609","1610","1611","1661","2463","2464","2465","2466","2467","2661","1016","1017","1018","1019","1020","1021","1061","1063","1403","1406","2005","2013","3008","3009","0606","0607","1428","1429","1432","1433","1434","3007","1401","0616","0664","1213","1214","0202","0611","0618","0663","1001","1003","1004","2476","2477","2479","2602","2603","2604","2605","0224","0604","0605","0613","0615","0215","0612","3061","0208","0217","0602","0608","1407","1417","1421","1423","1426","1436","1463","1464","1465","2003"]},{"id":"Sk20260809095258478","nazwa_zdarzenia":"Upa\u0142","stopien":"1","prawdopodobienstwo":"85","obowiazuje_do":"2026-08-10 18:00:00","obowiazuje_od":"2026-08-10 11:00:00","opublikowano":"2026-08-09 11:52:00","tresc":"Prognozuje si\u0119 upa\u0142. Temperatura maksymalna wyniesie od 30\u00b0C do 32\u00b0C.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["0207","0210","0261","0265","3011","3014","3015","3016","1411","1424","3012","3024","3026","3063","0221","0401","1462","0206","3003","0212","0415","3021","3022","3025","3030","0405","0408","0409","0410","0411","0412","1427","1435","3005","3006","3010","0205","0226","1419","3001","3013","3023","3004","3028","3029","0219","0419","1404","1408","1414","1416","1420","3062","3064","0418","0461","0463","0464","1402","0403","0407","0213"]}] \ No newline at end of file