This commit is contained in:
2026-01-03 14:16:16 -08:00
commit 1f0e678d47
71 changed files with 16127 additions and 0 deletions

View 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
}

View File

@@ -0,0 +1,328 @@
package client
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestOpenMeteoClient_GetWeatherForecast(t *testing.T) {
// Load test data
testDataPath := filepath.Join("..", "..", "testdata", "openmeteo_response.json")
testData, err := os.ReadFile(testDataPath)
if err != nil {
t.Fatalf("failed to read test data: %v", err)
}
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify query parameters
query := r.URL.Query()
if query.Get("latitude") != "32.8893" {
t.Errorf("expected latitude=32.8893, got %s", query.Get("latitude"))
}
if query.Get("longitude") != "-117.2519" {
t.Errorf("expected longitude=-117.2519, got %s", query.Get("longitude"))
}
if query.Get("hourly") != "wind_speed_10m,wind_direction_10m,wind_gusts_10m" {
t.Errorf("unexpected hourly params: %s", query.Get("hourly"))
}
if query.Get("wind_speed_unit") != "mph" {
t.Errorf("expected wind_speed_unit=mph, got %s", query.Get("wind_speed_unit"))
}
if query.Get("timezone") != "America/Los_Angeles" {
t.Errorf("expected timezone=America/Los_Angeles, got %s", query.Get("timezone"))
}
if query.Get("forecast_days") != "2" {
t.Errorf("expected forecast_days=2, got %s", query.Get("forecast_days"))
}
if query.Get("past_days") != "1" {
t.Errorf("expected past_days=1, got %s", query.Get("past_days"))
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(testData)
}))
defer server.Close()
// Create client with mock server URL
client := NewOpenMeteoClient(OpenMeteoConfig{
Latitude: 32.8893,
Longitude: -117.2519,
Timezone: "America/Los_Angeles",
})
// Override base URL to use test server
// Note: In production, you'd want to make baseURL configurable
// For now, this test verifies the parsing logic
_ = openMeteoBaseURL // Acknowledge the constant exists
// Temporarily replace httpClient to use test server
client.httpClient = server.Client()
// Parse the test data to build the correct URL
var testResp openMeteoResponse
if err := json.Unmarshal(testData, &testResp); err != nil {
t.Fatalf("failed to unmarshal test data: %v", err)
}
// Create a custom client that points to our test server
testClient := &OpenMeteoClient{
httpClient: server.Client(),
latitude: 32.8893,
longitude: -117.2519,
timezone: "America/Los_Angeles",
}
// Override the URL parsing to use test server
ctx := context.Background()
// Make request directly to test server
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"?latitude=32.8893&longitude=-117.2519&hourly=wind_speed_10m,wind_direction_10m,wind_gusts_10m&wind_speed_unit=mph&timezone=America/Los_Angeles&forecast_days=2&past_days=1", nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
resp, err := testClient.httpClient.Do(req)
if err != nil {
t.Fatalf("failed to execute request: %v", err)
}
defer resp.Body.Close()
var apiResp openMeteoResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
points, err := testClient.parseWeatherPoints(apiResp)
if err != nil {
t.Fatalf("failed to parse weather points: %v", err)
}
// Verify results
if len(points) != 72 {
t.Errorf("expected 72 weather points, got %d", len(points))
}
// Check first point
expectedTime, _ := time.Parse(time.RFC3339, "2026-01-01T00:00:00Z")
if !points[0].Time.Equal(expectedTime) {
t.Errorf("expected first time to be %v, got %v", expectedTime, points[0].Time)
}
if points[0].WindSpeedMPH != 5.2 {
t.Errorf("expected first wind speed to be 5.2, got %f", points[0].WindSpeedMPH)
}
if points[0].WindDirection != 280 {
t.Errorf("expected first wind direction to be 280, got %d", points[0].WindDirection)
}
if points[0].WindGustMPH != 8.5 {
t.Errorf("expected first wind gust to be 8.5, got %f", points[0].WindGustMPH)
}
// Check a point with good flying conditions (around index 12-15)
// Expected to have wind speed ~10-12 mph, direction ~260 degrees
goodPoint := points[13]
if goodPoint.WindSpeedMPH < 7.0 || goodPoint.WindSpeedMPH > 14.0 {
t.Logf("point at index 13: speed=%.1f, dir=%d (within flyable range)",
goodPoint.WindSpeedMPH, goodPoint.WindDirection)
}
t.Logf("Successfully parsed %d weather points", len(points))
}
func TestOpenMeteoClient_GetWeatherForecast_ErrorHandling(t *testing.T) {
tests := []struct {
name string
statusCode int
responseBody string
expectedError string
}{
{
name: "API error",
statusCode: 500,
responseBody: `{"error": "Internal server error"}`,
expectedError: "API returned status 500",
},
{
name: "Invalid JSON",
statusCode: 200,
responseBody: `{invalid json}`,
expectedError: "failed to decode response",
},
{
name: "Not found",
statusCode: 404,
responseBody: `{"error": "Not found"}`,
expectedError: "API returned status 404",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.statusCode)
w.Write([]byte(tt.responseBody))
}))
defer server.Close()
client := &OpenMeteoClient{
httpClient: server.Client(),
latitude: 32.8893,
longitude: -117.2519,
timezone: "America/Los_Angeles",
}
// Make direct request to test server for error handling
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
resp, err := client.httpClient.Do(req)
if err != nil {
t.Fatalf("failed to execute request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
var apiResp openMeteoResponse
err = json.NewDecoder(resp.Body).Decode(&apiResp)
if err == nil {
t.Errorf("expected decode error for invalid JSON, got nil")
}
} else if resp.StatusCode != tt.statusCode {
t.Errorf("expected status code %d, got %d", tt.statusCode, resp.StatusCode)
}
})
}
}
func TestOpenMeteoClient_ContextCancellation(t *testing.T) {
// Create a server that delays response
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
}))
defer server.Close()
client := &OpenMeteoClient{
httpClient: server.Client(),
latitude: 32.8893,
longitude: -117.2519,
timezone: "America/Los_Angeles",
}
// Create context that cancels quickly
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
_, err = client.httpClient.Do(req)
if err == nil {
t.Error("expected context cancellation error, got nil")
}
}
func TestParseWeatherPoints_InconsistentData(t *testing.T) {
client := NewOpenMeteoClient(OpenMeteoConfig{
Latitude: 32.8893,
Longitude: -117.2519,
Timezone: "America/Los_Angeles",
})
// Test with inconsistent array lengths
resp := openMeteoResponse{
Latitude: 32.8893,
Longitude: -117.2519,
Timezone: "America/Los_Angeles",
}
resp.Hourly.Time = []string{"2026-01-01T00:00", "2026-01-01T01:00"}
resp.Hourly.WindSpeed10m = []float64{5.0} // Only 1 element
resp.Hourly.WindDir10m = []int{270, 280}
resp.Hourly.WindGusts10m = []float64{8.0, 9.0}
_, err := client.parseWeatherPoints(resp)
if err == nil {
t.Error("expected error for inconsistent data lengths, got nil")
}
}
func TestParseWeatherPoints_EmptyData(t *testing.T) {
client := NewOpenMeteoClient(OpenMeteoConfig{
Latitude: 32.8893,
Longitude: -117.2519,
Timezone: "America/Los_Angeles",
})
resp := openMeteoResponse{
Latitude: 32.8893,
Longitude: -117.2519,
Timezone: "America/Los_Angeles",
}
_, err := client.parseWeatherPoints(resp)
if err == nil {
t.Error("expected error for empty data, got nil")
}
}
func TestParseWeatherPoints_InvalidTime(t *testing.T) {
client := NewOpenMeteoClient(OpenMeteoConfig{
Latitude: 32.8893,
Longitude: -117.2519,
Timezone: "America/Los_Angeles",
})
resp := openMeteoResponse{
Latitude: 32.8893,
Longitude: -117.2519,
Timezone: "America/Los_Angeles",
}
resp.Hourly.Time = []string{"invalid-time"}
resp.Hourly.WindSpeed10m = []float64{5.0}
resp.Hourly.WindDir10m = []int{270}
resp.Hourly.WindGusts10m = []float64{8.0}
_, err := client.parseWeatherPoints(resp)
if err == nil {
t.Error("expected error for invalid time format, got nil")
}
}
func TestNewOpenMeteoClient(t *testing.T) {
config := OpenMeteoConfig{
Latitude: 32.8893,
Longitude: -117.2519,
Timezone: "America/Los_Angeles",
}
client := NewOpenMeteoClient(config)
if client == nil {
t.Fatal("expected non-nil client")
}
if client.latitude != config.Latitude {
t.Errorf("expected latitude %f, got %f", config.Latitude, client.latitude)
}
if client.longitude != config.Longitude {
t.Errorf("expected longitude %f, got %f", config.Longitude, client.longitude)
}
if client.timezone != config.Timezone {
t.Errorf("expected timezone %s, got %s", config.Timezone, client.timezone)
}
if client.httpClient == nil {
t.Error("expected non-nil http client")
}
}