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 }