-
Notifications
You must be signed in to change notification settings - Fork 504
Add backend for yr.no #210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,317 @@ | ||||||||||||||||||||
| package backends | ||||||||||||||||||||
|
|
||||||||||||||||||||
| import ( | ||||||||||||||||||||
| "encoding/json" | ||||||||||||||||||||
| "flag" | ||||||||||||||||||||
| "fmt" | ||||||||||||||||||||
| "io" | ||||||||||||||||||||
| "log" | ||||||||||||||||||||
| "net/http" | ||||||||||||||||||||
| "regexp" | ||||||||||||||||||||
| "strings" | ||||||||||||||||||||
| "time" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| "github.com/schachmat/wego/iface" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| type yrNoConfig struct { | ||||||||||||||||||||
| userAgent string | ||||||||||||||||||||
| debug bool | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| type yrNoResponse struct { | ||||||||||||||||||||
| Properties struct { | ||||||||||||||||||||
| Timeseries []timeSeriesEntry `json:"timeseries"` | ||||||||||||||||||||
| } `json:"properties"` | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| type timeSeriesEntry struct { | ||||||||||||||||||||
| Dt string `json:"time"` | ||||||||||||||||||||
| Data struct { | ||||||||||||||||||||
| Instant struct { | ||||||||||||||||||||
| Details struct { | ||||||||||||||||||||
| AirPressure float32 `json:"air_pressure_at_sea_level"` | ||||||||||||||||||||
| TempC float32 `json:"air_temperature"` | ||||||||||||||||||||
| RelativeHumidity float32 `json:"relative_humidity"` | ||||||||||||||||||||
| WindSpeed float32 `json:"wind_speed"` | ||||||||||||||||||||
| WindFromDirection float32 `json:"wind_from_direction"` | ||||||||||||||||||||
| } `json:"details"` | ||||||||||||||||||||
| } `json:"instant"` | ||||||||||||||||||||
| NextOneHour struct { | ||||||||||||||||||||
| Summary struct { | ||||||||||||||||||||
| SymbolCode string `json:"symbol_code"` | ||||||||||||||||||||
| } `json:"summary"` | ||||||||||||||||||||
| Details struct { | ||||||||||||||||||||
| Precipitation float32 `json:"precipitation_amount"` | ||||||||||||||||||||
| } `json:"details"` | ||||||||||||||||||||
| } `json:"next_1_hours"` | ||||||||||||||||||||
| } `json:"data"` | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const ( | ||||||||||||||||||||
| yrNoURI = "https://api.met.no/weatherapi/locationforecast/2.0/compact?%s" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| func (c *yrNoConfig) Setup() { | ||||||||||||||||||||
| flag.StringVar(&c.userAgent, "yrno-user-agent", "", "yr.no backend: the user agent to use. See https://docs.api.met.no/doc/TermsOfService.html for details") | ||||||||||||||||||||
| flag.BoolVar(&c.debug, "yrno-debug", false, "yr.no backend: print raw requests and responses") | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| func (c *yrNoConfig) fetch(url string) (*yrNoResponse, error) { | ||||||||||||||||||||
| client := &http.Client{} | ||||||||||||||||||||
|
|
||||||||||||||||||||
| req, err := http.NewRequest("GET", url, nil) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| log.Fatalln(err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| req.Header.Set("User-Agent", c.userAgent) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| res, err := client.Do(req) | ||||||||||||||||||||
| if c.debug { | ||||||||||||||||||||
| fmt.Printf("Fetching %s\n", url) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return nil, fmt.Errorf(" Unable to get (%s) %v", url, err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| defer res.Body.Close() | ||||||||||||||||||||
| body, err := io.ReadAll(res.Body) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return nil, fmt.Errorf("unable to read response body (%s): %v", url, err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
||||||||||||||||||||
| if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { | |
| bodySnippet := strings.TrimSpace(string(body)) | |
| if len(bodySnippet) > 512 { | |
| bodySnippet = bodySnippet[:512] + "..." | |
| } | |
| return nil, fmt.Errorf("unexpected response status for %s: %s; body: %s", url, res.Status, bodySnippet) | |
| } |
Copilot
AI
Apr 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
parseDaily() never appends the final day after the loop ends, so if the loop completes naturally (e.g., user requests all available days) the last day of forecast is dropped. Append *day after the loop when day != nil and len(forecast) < numdays (or when numdays exceeds available data).
| } | |
| } | |
| if day != nil && len(forecast) < numdays { | |
| forecast = append(forecast, *day) | |
| } |
Copilot
AI
Apr 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
parseCond() rebuilds large descriptionMap/codemap maps on every call, which is expensive given this runs once per timeseries entry. Move these maps to package-level var/const (or initialize once) to avoid repeated allocations and improve throughput.
Copilot
AI
Apr 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
parseCond() ignores the time.Parse error (ret.Time, _ = ...) and still returns nil error, which can silently produce a zero timestamp and break day grouping/sorting. Handle the parse error and return it to the caller so parseDaily()/Fetch() can log/skip appropriately.
| ret.Time, _ = time.Parse(time.RFC3339, entry.Dt) | |
| parsedTime, err := time.Parse(time.RFC3339, entry.Dt) | |
| if err != nil { | |
| return ret, err | |
| } | |
| ret.Time = parsedTime |
Copilot
AI
Apr 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If location is not a lat,lon pair, loc remains empty but the code still calls the API with an empty query string. This should fail fast with a helpful message (similar to smhi), e.g., log.Fatalf explaining that this backend only supports latitude,longitude input.
Copilot
AI
Apr 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resp.Properties.Timeseries[0] is accessed without checking that Timeseries is non-empty. In error scenarios (or unexpected API changes) this can panic with an index-out-of-range. Guard with a length check and return/exit with an explicit "no timeseries in response" error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fetch()callslog.Fatalln(err)ifhttp.NewRequestfails, which terminates the whole program even though the method already returns anerror. Prefer returning a wrapped error here so callers can handle/report it consistently (like the other error paths in this function).