diff options
Diffstat (limited to 'internal/imgw')
| -rw-r--r-- | internal/imgw/imgw.go | 156 | ||||
| -rw-r--r-- | internal/imgw/imgw_test.go | 190 | ||||
| -rw-r--r-- | internal/imgw/testdata/gugik_abroad.json | 1 | ||||
| -rw-r--r-- | internal/imgw/testdata/warnings.json | 1 |
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 |
