aboutsummaryrefslogtreecommitdiff
path: root/internal/imgw/imgw.go
blob: 48ffe1992471ccb761a5332d0e507837fef2341e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
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
}