// 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 }