Files
paragliding/backend/internal/client/openmeteo.go
2026-01-03 14:16:16 -08:00

169 lines
4.9 KiB
Go

package client
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/scottyah/paragliding/internal/model"
)
const (
openMeteoBaseURL = "https://api.open-meteo.com/v1/forecast"
)
// OpenMeteoClient is a client for the Open-Meteo weather API
type OpenMeteoClient struct {
httpClient *http.Client
latitude float64
longitude float64
timezone string
}
// OpenMeteoConfig contains configuration for the Open-Meteo client
type OpenMeteoConfig struct {
Latitude float64
Longitude float64
Timezone string // IANA timezone (e.g., "America/Los_Angeles")
}
// NewOpenMeteoClient creates a new Open-Meteo API client
func NewOpenMeteoClient(config OpenMeteoConfig) *OpenMeteoClient {
return &OpenMeteoClient{
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
latitude: config.Latitude,
longitude: config.Longitude,
timezone: config.Timezone,
}
}
// openMeteoResponse represents the JSON response from Open-Meteo API
type openMeteoResponse struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Timezone string `json:"timezone"`
Hourly struct {
Time []string `json:"time"`
WindSpeed10m []float64 `json:"wind_speed_10m"`
WindDir10m []int `json:"wind_direction_10m"`
WindGusts10m []float64 `json:"wind_gusts_10m"`
} `json:"hourly"`
}
// GetWeatherForecast fetches weather data from Open-Meteo API
// It retrieves 1 day of past data and 2 days of forecast data
func (c *OpenMeteoClient) GetWeatherForecast(ctx context.Context) ([]model.WeatherPoint, error) {
// Build request URL with query parameters
reqURL, err := url.Parse(openMeteoBaseURL)
if err != nil {
return nil, fmt.Errorf("failed to parse base URL: %w", err)
}
query := reqURL.Query()
query.Set("latitude", fmt.Sprintf("%.4f", c.latitude))
query.Set("longitude", fmt.Sprintf("%.4f", c.longitude))
query.Set("hourly", "wind_speed_10m,wind_direction_10m,wind_gusts_10m")
query.Set("wind_speed_unit", "mph")
query.Set("timezone", c.timezone)
query.Set("forecast_days", "2")
query.Set("past_days", "1")
reqURL.RawQuery = query.Encode()
// Create HTTP request with context
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Execute request
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch weather data: %w", err)
}
defer resp.Body.Close()
// Check status code
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
// Parse JSON response
var apiResp openMeteoResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// Convert to WeatherPoint slice
points, err := c.parseWeatherPoints(apiResp)
if err != nil {
return nil, fmt.Errorf("failed to parse weather points: %w", err)
}
return points, nil
}
// parseWeatherPoints converts the API response into a slice of WeatherPoint
func (c *OpenMeteoClient) parseWeatherPoints(resp openMeteoResponse) ([]model.WeatherPoint, error) {
if len(resp.Hourly.Time) == 0 {
return nil, fmt.Errorf("no hourly data in response")
}
// Validate that all arrays have the same length
dataLen := len(resp.Hourly.Time)
if len(resp.Hourly.WindSpeed10m) != dataLen ||
len(resp.Hourly.WindDir10m) != dataLen ||
len(resp.Hourly.WindGusts10m) != dataLen {
return nil, fmt.Errorf("inconsistent data array lengths in response")
}
// Load timezone location
loc, err := time.LoadLocation(resp.Timezone)
if err != nil {
// Fallback to UTC if timezone can't be loaded
loc = time.UTC
}
points := make([]model.WeatherPoint, 0, dataLen)
for i := 0; i < dataLen; i++ {
// Parse timestamp - API returns ISO8601 format without timezone (e.g., "2026-01-01T00:00")
// Try RFC3339 first, then fall back to ISO8601 without timezone
var t time.Time
var err error
// Try RFC3339 format first (with timezone)
t, err = time.Parse(time.RFC3339, resp.Hourly.Time[i])
if err != nil {
// Try ISO8601 format without timezone (e.g., "2006-01-02T15:04")
t, err = time.Parse("2006-01-02T15:04", resp.Hourly.Time[i])
if err != nil {
// Try with seconds if present
t, err = time.Parse("2006-01-02T15:04:05", resp.Hourly.Time[i])
if err != nil {
return nil, fmt.Errorf("failed to parse time at index %d: %w", i, err)
}
}
// Apply timezone to parsed time
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), loc)
}
point := model.WeatherPoint{
Time: t,
WindSpeedMPH: resp.Hourly.WindSpeed10m[i],
WindDirection: resp.Hourly.WindDir10m[i],
WindGustMPH: resp.Hourly.WindGusts10m[i],
}
points = append(points, point)
}
return points, nil
}