init
This commit is contained in:
168
backend/internal/client/openmeteo.go
Normal file
168
backend/internal/client/openmeteo.go
Normal file
@@ -0,0 +1,168 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user