diff --git a/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs index 6ddc2c1..354d47b 100644 --- a/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs @@ -65,7 +65,9 @@ public async Task GetOrFetchAsync_CacheHit_DoesNotCallHttp() { Content = new StringContent("{}", Encoding.UTF8, "application/json"), }); - var factory = new StubHttpClientFactory(new HttpClient(handler)); + var factory = new StubHttpClientFactory( + new HttpClient(handler) { BaseAddress = new Uri("https://api.open-meteo.com") } + ); var config = new ConfigurationBuilder().AddInMemoryCollection().Build(); var service = new OpenMeteoWeatherLookupService( @@ -101,7 +103,9 @@ public async Task GetOrFetchAsync_SecondCall_UsesCacheAndCallsHttpOnce() ), }); - var factory = new StubHttpClientFactory(new HttpClient(handler)); + var factory = new StubHttpClientFactory( + new HttpClient(handler) { BaseAddress = new Uri("https://api.open-meteo.com") } + ); var config = new ConfigurationBuilder().AddInMemoryCollection().Build(); var service = new OpenMeteoWeatherLookupService( @@ -164,7 +168,9 @@ public async Task GetOrFetchAsync_AfterServiceRestart_UsesPersistedCacheWithoutH "application/json" ), }); - var factory = new StubHttpClientFactory(new HttpClient(handler)); + var factory = new StubHttpClientFactory( + new HttpClient(handler) { BaseAddress = new Uri("https://api.open-meteo.com") } + ); var config = new ConfigurationBuilder().AddInMemoryCollection().Build(); await using var restartedContext = CreateSqliteContext(connection); diff --git a/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs b/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs index c861507..3604567 100644 --- a/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs +++ b/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs @@ -101,9 +101,8 @@ ILogger logger // Determine which API to call (forecast vs. archive) var daysDiff = (int)(DateTime.UtcNow.Date - dateTimeUtc.Date).TotalDays; var isHistorical = daysDiff > 92; - var endpoint = isHistorical - ? "https://archive-api.open-meteo.com/v1/archive" - : "https://api.open-meteo.com/v1/forecast"; + var clientName = isHistorical ? "OpenMeteoArchive" : "OpenMeteoForecast"; + var requestPath = isHistorical ? "/v1/archive" : "/v1/forecast"; var apiKey = configuration["WeatherLookup:ApiKey"]; var apiKeyParam = string.IsNullOrWhiteSpace(apiKey) @@ -111,35 +110,34 @@ ILogger logger : $"&apikey={Uri.EscapeDataString(apiKey)}"; // Build query parameters - var pastDaysParam = isHistorical ? "" : $"&past_days={Math.Min(daysDiff + 1, 92)}"; + // Note: past_days is mutually exclusive with start_date/end_date on the Open-Meteo API. var queryParams = $"?latitude={Uri.EscapeDataString(latitude.ToString(CultureInfo.InvariantCulture))}" + $"&longitude={Uri.EscapeDataString(longitude.ToString(CultureInfo.InvariantCulture))}" + $"&start_date={dateTimeUtc.Date:yyyy-MM-dd}" + $"&end_date={dateTimeUtc.Date:yyyy-MM-dd}" - + $"{pastDaysParam}" + "&hourly=temperature_2m,wind_speed_10m,wind_direction_10m,relative_humidity_2m,cloud_cover,precipitation,weather_code" + "&temperature_unit=fahrenheit" + "&wind_speed_unit=mph" + "&timezone=auto" + apiKeyParam; - try { - var client = httpClientFactory.CreateClient("OpenMeteo"); + var client = httpClientFactory.CreateClient(clientName); using var response = await client.GetAsync( - $"{endpoint}{queryParams}", + $"{requestPath}{queryParams}", cancellationToken ); if (!response.IsSuccessStatusCode) { logger.LogWarning( - "Open-Meteo lookup failed for rounded {LatitudeRounded},{LongitudeRounded} at {UtcHour} with status {StatusCode}", + "Open-Meteo lookup failed for rounded {LatitudeRounded},{LongitudeRounded} at {UtcHour} with status {StatusCode}: {ResponseContent}", latRounded, lonRounded, dateTimeUtc, - response.StatusCode + response.StatusCode, + await response.Content.ReadAsStringAsync(cancellationToken) ); // Cache failure for short period to avoid hammering API diff --git a/src/BikeTracking.Api/Program.cs b/src/BikeTracking.Api/Program.cs index 95b919b..7bd3ec8 100644 --- a/src/BikeTracking.Api/Program.cs +++ b/src/BikeTracking.Api/Program.cs @@ -15,6 +15,14 @@ var connectionString = builder.Configuration.GetConnectionString("BikeTracking") ?? "Data Source=biketracking.local.db"; +var eiaGasPriceBaseUrl = + builder.Configuration["ExternalApis:EiaGasPriceBaseUrl"] ?? "https+http://eia-gas-price"; +var openMeteoForecastBaseUrl = + builder.Configuration["ExternalApis:OpenMeteoForecastBaseUrl"] + ?? "https+http://open-meteo-forecast"; +var openMeteoArchiveBaseUrl = + builder.Configuration["ExternalApis:OpenMeteoArchiveBaseUrl"] + ?? "https+http://open-meteo-archive"; builder.Services.Configure(builder.Configuration.GetSection("Identity")); builder.Services.AddDbContext(options => @@ -48,14 +56,23 @@ "EiaGasPrice", client => { - client.BaseAddress = new Uri("https://api.eia.gov"); + client.BaseAddress = new Uri(eiaGasPriceBaseUrl); client.Timeout = TimeSpan.FromSeconds(10); } ); builder.Services.AddHttpClient( - "OpenMeteo", + "OpenMeteoForecast", client => { + client.BaseAddress = new Uri(openMeteoForecastBaseUrl); + client.Timeout = TimeSpan.FromSeconds(5); + } +); +builder.Services.AddHttpClient( + "OpenMeteoArchive", + client => + { + client.BaseAddress = new Uri(openMeteoArchiveBaseUrl); client.Timeout = TimeSpan.FromSeconds(5); } ); diff --git a/src/BikeTracking.AppHost/AppHost.cs b/src/BikeTracking.AppHost/AppHost.cs index 2db7464..ea01664 100644 --- a/src/BikeTracking.AppHost/AppHost.cs +++ b/src/BikeTracking.AppHost/AppHost.cs @@ -1,5 +1,19 @@ var builder = DistributedApplication.CreateBuilder(args); +var openMeteoForecast = builder.AddExternalService( + "open-meteo-forecast", + "https://api.open-meteo.com/" +); +var openMeteoArchive = builder.AddExternalService( + "open-meteo-archive", + "https://archive-api.open-meteo.com/" +); +var eiaGasPrice = builder.AddExternalService("eia-gas-price", "https://api.eia.gov/"); + +const string eiaGasPriceBaseUrl = "https+http://eia-gas-price"; +const string openMeteoForecastBaseUrl = "https+http://open-meteo-forecast"; +const string openMeteoArchiveBaseUrl = "https+http://open-meteo-archive"; + // Local SQL Server database for development // var database = builder.AddSqlServer("sql").AddDatabase("biketracking"); // TODO https://aspire.dev/integrations/databases/sqlite/sqlite-get-started/?lang=csharp @@ -10,6 +24,12 @@ var apiService = builder .AddProject("api") //.WithReference(database) + .WithReference(openMeteoForecast) + .WithReference(openMeteoArchive) + .WithReference(eiaGasPrice) + .WithEnvironment("ExternalApis__EiaGasPriceBaseUrl", eiaGasPriceBaseUrl) + .WithEnvironment("ExternalApis__OpenMeteoForecastBaseUrl", openMeteoForecastBaseUrl) + .WithEnvironment("ExternalApis__OpenMeteoArchiveBaseUrl", openMeteoArchiveBaseUrl) .WithHttpHealthCheck("/health") .WithExternalHttpEndpoints(); diff --git a/src/BikeTracking.Frontend/playwright.config.ts b/src/BikeTracking.Frontend/playwright.config.ts index d0e575d..cca0d59 100644 --- a/src/BikeTracking.Frontend/playwright.config.ts +++ b/src/BikeTracking.Frontend/playwright.config.ts @@ -45,6 +45,10 @@ export default defineConfig({ env: { ASPNETCORE_URLS: e2eApiUrl, PLAYWRIGHT_E2E: "1", + ExternalApis__EiaGasPriceBaseUrl: "https://api.eia.gov", + ExternalApis__OpenMeteoForecastBaseUrl: "https://api.open-meteo.com", + ExternalApis__OpenMeteoArchiveBaseUrl: + "https://archive-api.open-meteo.com", // Use a dedicated E2E database so test runs never touch the local dev DB. // ASP.NET Core maps ConnectionStrings__ to ConnectionStrings[name]. ConnectionStrings__BikeTracking: `Data Source=${e2eDbPath}`,