Files
Maven/internal/weather/openmeteo.go
T
claude e8ece874b1 weather: check HTTP status before decoding open-meteo replies (V-589)
Neither CurrentWeather nor geocodeOne checked resp.StatusCode, so a
non-200 forecast reply decoded into a zero-value struct reported as a
real 0-degree answer, and a non-200 geocode reply decoded into an empty
result list and was reported as ErrLocationUnknown — blaming the owner
for a service outage. Both now check http.StatusOK first, matching the
sibling kiwix and websearch clients, and return a wrapped error naming
the status instead.

Adds httptest coverage for a 500 from the forecast endpoint, a 500 from
the geocode endpoint, and a genuine empty geocode result, asserting each
takes a different path.
2026-08-06 02:47:24 +04:00

219 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package weather
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"unicode"
"github.com/kami/maven/internal/morph"
)
// clientTimeout — the whole request, geocode or forecast. Both are one call to
// a public API over the internet rather than a LAN service, hence longer than
// a bare "it's slow" budget; there is no retry behind it, so a slow reply
// still costs the caller the whole wait.
const clientTimeout = 10 * time.Second
type OpenMeteoProvider struct {
httpClient *http.Client
}
func NewOpenMeteoProvider() *OpenMeteoProvider {
return &OpenMeteoProvider{
httpClient: &http.Client{Timeout: clientTimeout},
}
}
func (p *OpenMeteoProvider) SetClient(c *http.Client) {
p.httpClient = c
}
type geoResult struct {
Name string `json:"name"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Country string `json:"country"`
}
type geoResponse struct {
Results []geoResult `json:"results"`
}
type currentWeather struct {
Temperature float64 `json:"temperature"`
WMO int `json:"weathercode"`
WindSpeed float64 `json:"windspeed"`
}
type forecastResponse struct {
CurrentWeather currentWeather `json:"current_weather"`
}
var wmoCodes = map[int]string{
0: "ясно",
1: "преимущественно ясно",
2: "облачно",
3: "пасмурно",
45: "туман",
51: "морось",
61: "дождь",
71: "снег",
95: "гроза",
}
func (p *OpenMeteoProvider) CurrentWeather(ctx context.Context, location string) (Weather, error) {
lat, lon, name, err := p.geocode(ctx, location)
if err != nil {
return Weather{}, fmt.Errorf("open-meteo: geocode: %w", err)
}
u := fmt.Sprintf("https://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f&current_weather=true", lat, lon)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return Weather{}, fmt.Errorf("open-meteo: request: %w", err)
}
resp, err := p.httpClient.Do(req)
if err != nil {
return Weather{}, fmt.Errorf("open-meteo: http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Weather{}, fmt.Errorf("open-meteo forecast: http %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return Weather{}, fmt.Errorf("open-meteo: read: %w", err)
}
var forecast forecastResponse
if err := json.Unmarshal(body, &forecast); err != nil {
return Weather{}, fmt.Errorf("open-meteo: decode: %w", err)
}
condition := wmoCodes[forecast.CurrentWeather.WMO]
if condition == "" {
condition = "неизвестно"
}
return Weather{
Location: name,
Temperature: forecast.CurrentWeather.Temperature,
Condition: condition,
}, nil
}
// locationCandidates — the spellings to try for a place taken out of a spoken
// sentence, in order. He says "какая погода в Казани", so the word arrives in
// the prepositional case and the geocoder wants the nominative (Vikunja #421).
//
// The dictionary answers first (Vikunja #530). internal/morph lemmatises
// "Уфе" to "Уфа" and "Москве" to "Москва", which is the same question this
// used to guess at by reversing endings, asked of something that knows.
//
// The reversals stay behind it, because the dictionary does not know every
// place: "Твери" and "Перми" come back unchanged, and a final "и" is usually a
// soft sign. A final "е" is usually a nominative "а" (Москве → Москва) or
// nothing at all (Лондоне → Лондон). Indeclinable names — Тбилиси, Сочи, Осло —
// are already nominative and the first candidate answers, which is why the word
// as spoken is always tried before anything derived from it.
//
// There used to be a four-rune floor here, so "Уфе" was asked as spoken and
// "Уфа" was never tried. The floor was there to stop a two-letter stem, and the
// stem length is what it now tests.
//
// Nothing here is a guess about the weather: a wrong candidate finds no city
// and the caller says so. It only decides which strings are worth asking about.
func locationCandidates(location string) []string {
out := []string{location}
add := func(s string) {
if s == "" || s == location {
return
}
for _, seen := range out {
if seen == s {
return
}
}
out = append(out, s)
}
add(titleFirst(morph.Lemma(location)))
r := []rune(location)
if len(r) < 3 {
return out
}
stem := string(r[:len(r)-1])
switch r[len(r)-1] {
case 'е', 'Е':
add(stem + "а")
add(stem)
case 'и', 'И':
add(stem + "ь")
add(stem)
case 'у', 'У', 'ю', 'Ю':
add(stem + "а")
}
return out
}
// titleFirst restores the leading capital a place name carries. morph.Lemma
// answers lowercased, because a lemma is a dictionary entry and the dictionary
// has no opinion about proper nouns.
func titleFirst(s string) string {
r := []rune(s)
if len(r) == 0 {
return s
}
return string(unicode.ToUpper(r[0])) + string(r[1:])
}
func (p *OpenMeteoProvider) geocode(ctx context.Context, location string) (lat, lon float64, name string, err error) {
for _, cand := range locationCandidates(location) {
lat, lon, name, err = p.geocodeOne(ctx, cand)
if err == nil {
return lat, lon, name, nil
}
}
return 0, 0, "", err
}
func (p *OpenMeteoProvider) geocodeOne(ctx context.Context, location string) (lat, lon float64, name string, err error) {
u := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=ru&format=json", url.QueryEscape(location))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return 0, 0, "", err
}
resp, err := p.httpClient.Do(req)
if err != nil {
return 0, 0, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, 0, "", fmt.Errorf("open-meteo geocode: http %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, 0, "", err
}
var geo geoResponse
if err := json.Unmarshal(body, &geo); err != nil {
return 0, 0, "", err
}
if len(geo.Results) == 0 {
return 0, 0, "", fmt.Errorf("%w: %q", ErrLocationUnknown, location)
}
r := geo.Results[0]
return r.Latitude, r.Longitude, r.Name, nil
}