diff --git a/src/Clima.Api/Clima.Api.csproj b/src/Clima.Api/Clima.Api.csproj index 7e496b4..db81373 100644 --- a/src/Clima.Api/Clima.Api.csproj +++ b/src/Clima.Api/Clima.Api.csproj @@ -1,9 +1,10 @@ - + net9.0 enable enable + b004094a-ecbf-4bb4-aa67-af634a6090d9 diff --git a/src/Clima.Api/Controllers/WeatherController.cs b/src/Clima.Api/Controllers/WeatherController.cs new file mode 100644 index 0000000..086b3c7 --- /dev/null +++ b/src/Clima.Api/Controllers/WeatherController.cs @@ -0,0 +1,55 @@ +using Clima.Core.Entities; +using Clima.Infrastructure.Persistence.Repositories; +using Microsoft.AspNetCore.Mvc; + +namespace Clima.Api.Controllers +{ + /// + /// Provee endpoints para acceder a los datos del pronóstico del tiempo. + /// + [ApiController] + [Route("api/[controller]")] + public class WeatherController : ControllerBase + { + private readonly IPronosticoRepository _pronosticoRepository; + private readonly ILogger _logger; + + public WeatherController( + IPronosticoRepository pronosticoRepository, + ILogger logger) + { + _pronosticoRepository = pronosticoRepository; + _logger = logger; + } + + /// + /// Obtiene el pronóstico de 5 días para una estación meteorológica específica. + /// + /// El nombre de la estación (ej. "LA_PLATA_AERO"). + /// Una lista de pronósticos ordenados por fecha. + [HttpGet("{nombreEstacion}")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task GetForecastByStation(string nombreEstacion) + { + try + { + var pronosticos = await _pronosticoRepository.ObtenerPorEstacionAsync(nombreEstacion); + + if (pronosticos == null || !pronosticos.Any()) + { + _logger.LogWarning("No se encontraron datos de pronóstico para la estación: {Estacion}", nombreEstacion); + return NotFound($"No se encontraron datos para la estación: {nombreEstacion}"); + } + + return Ok(pronosticos); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error al obtener el pronóstico para la estación: {Estacion}", nombreEstacion); + return StatusCode(StatusCodes.Status500InternalServerError, "Ocurrió un error interno al procesar la solicitud."); + } + } + } +} \ No newline at end of file diff --git a/src/Clima.Api/Program.cs b/src/Clima.Api/Program.cs index ee9d65d..805c31f 100644 --- a/src/Clima.Api/Program.cs +++ b/src/Clima.Api/Program.cs @@ -1,41 +1,50 @@ +using Clima.Database.Migrations; +using Clima.Infrastructure.Persistence; +using Clima.Infrastructure.Persistence.Repositories; +using FluentMigrator.Runner; + var builder = WebApplication.CreateBuilder(args); -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); +// --- Registro de Servicios --- + +// 1. Fábrica de Conexiones +builder.Services.AddSingleton(); + +// 2. Repositorio +builder.Services.AddScoped(); + +// 3. FluentMigrator para la gestión de la base de datos +builder.Services + .AddFluentMigratorCore() + .ConfigureRunner(rb => rb + .AddSqlServer() + .WithGlobalConnectionString(builder.Configuration.GetConnectionString("DefaultConnection")) + .ScanIn(typeof(CreatePronosticoTable).Assembly).For.Migrations()) + .AddLogging(lb => lb.AddFluentMigratorConsole()); + +// 4. Servicios estándar de la API +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); var app = builder.Build(); -// Configure the HTTP request pipeline. +// --- Pipeline de Ejecución --- + +// Ejecutar migraciones al inicio para crear/actualizar la base de datos +using (var scope = app.Services.CreateScope()) +{ + var migrationRunner = scope.ServiceProvider.GetRequiredService(); + migrationRunner.MigrateUp(); +} + if (app.Environment.IsDevelopment()) { - app.MapOpenApi(); + app.UseSwagger(); + app.UseSwaggerUI(); } app.UseHttpsRedirection(); - -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithName("GetWeatherForecast"); - -app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} +app.UseAuthorization(); +app.MapControllers(); +app.Run(); \ No newline at end of file diff --git a/src/Clima.Api/appsettings.json b/src/Clima.Api/appsettings.json index 10f68b8..4203181 100644 --- a/src/Clima.Api/appsettings.json +++ b/src/Clima.Api/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, + "ConnectionStrings": { + "DefaultConnection": "" + }, "AllowedHosts": "*" } diff --git a/src/Clima.Core/Entities/Pronostico.cs b/src/Clima.Core/Entities/Pronostico.cs index 15c9f8c..a3ce5ea 100644 --- a/src/Clima.Core/Entities/Pronostico.cs +++ b/src/Clima.Core/Entities/Pronostico.cs @@ -1,13 +1,13 @@ namespace Clima.Core.Entities { - public class Pronostico - { - public long Id { get; set; } - public string Estacion { get; set; } = string.Empty; - public DateTime FechaHora { get; set; } - public decimal TemperaturaC { get; set; } - public int VientoDirGrados { get; set; } - public int VientoKmh { get; set; } - public decimal PrecipitacionMm { get; set; } - } + public class Pronostico + { + public long Id { get; set; } + public string Estacion { get; set; } = string.Empty; + public DateTime FechaHora { get; set; } + public decimal TemperaturaC { get; set; } + public int VientoDirGrados { get; set; } + public int VientoKmh { get; set; } + public decimal PrecipitacionMm { get; set; } + } } \ No newline at end of file diff --git a/src/Clima.Infrastructure/DataFetchers/IDataFetcher.cs b/src/Clima.Infrastructure/DataFetchers/IDataFetcher.cs new file mode 100644 index 0000000..412fcc1 --- /dev/null +++ b/src/Clima.Infrastructure/DataFetchers/IDataFetcher.cs @@ -0,0 +1,8 @@ +namespace Clima.Infrastructure.DataFetchers +{ + public interface IDataFetcher + { + string SourceName { get; } + Task<(bool Success, string Message)> FetchDataAsync(); + } +} \ No newline at end of file diff --git a/src/Clima.Infrastructure/DataFetchers/SmnEtlFetcher.cs b/src/Clima.Infrastructure/DataFetchers/SmnEtlFetcher.cs new file mode 100644 index 0000000..41b8dec --- /dev/null +++ b/src/Clima.Infrastructure/DataFetchers/SmnEtlFetcher.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Clima.Core.Entities; +using Clima.Infrastructure.Persistence.Repositories; +using Microsoft.Extensions.Logging; + +namespace Clima.Infrastructure.DataFetchers +{ + /// + /// Realiza el proceso ETL completo para los datos de pronóstico de 5 días del SMN. + /// + public class SmnEtlFetcher : IDataFetcher + { + public string SourceName => "SMN_Forecast_5_Days"; + private const string DataUrl = "https://ssl.smn.gob.ar/dpd/zipopendata.php?dato=pron5d"; + + private readonly IHttpClientFactory _httpClientFactory; + private readonly IPronosticoRepository _pronosticoRepository; + private readonly ILogger _logger; + + public SmnEtlFetcher( + IHttpClientFactory httpClientFactory, + IPronosticoRepository pronosticoRepository, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _pronosticoRepository = pronosticoRepository; + _logger = logger; + } + + public async Task<(bool Success, string Message)> FetchDataAsync() + { + _logger.LogInformation("Iniciando proceso ETL para {SourceName}.", SourceName); + try + { + // 1. EXTRACCIÓN (E) + _logger.LogInformation("Descargando archivo ZIP desde la fuente de datos..."); + var zipBytes = await DownloadZipFileAsync(); + if (zipBytes == null || zipBytes.Length == 0) + { + return (false, "La descarga del archivo ZIP falló o el archivo está vacío."); + } + + // 2. TRANSFORMACIÓN (T) + _logger.LogInformation("Parseando los datos del pronóstico desde el archivo de texto..."); + var pronosticosPorEstacion = ParseTxtContent(zipBytes); + if (!pronosticosPorEstacion.Any()) + { + return (true, "Proceso completado, pero no se encontraron datos de pronóstico válidos para procesar."); + } + + // 3. CARGA (L) + _logger.LogInformation("Actualizando la base de datos con {Count} estaciones.", pronosticosPorEstacion.Count); + foreach (var kvp in pronosticosPorEstacion) + { + var estacion = kvp.Key; + var pronosticos = kvp.Value; + await _pronosticoRepository.ReemplazarPronosticosPorEstacionAsync(estacion, pronosticos); + _logger.LogInformation("Datos para la estación '{Estacion}' actualizados ({Count} registros).", estacion, pronosticos.Count); + } + + return (true, $"Proceso ETL completado exitosamente. Se procesaron {pronosticosPorEstacion.Count} estaciones."); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Falló el proceso ETL para {SourceName}.", SourceName); + return (false, $"Error crítico: {ex.Message}"); + } + } + + private async Task DownloadZipFileAsync() + { + var client = _httpClientFactory.CreateClient("SmnApiClient"); + return await client.GetByteArrayAsync(DataUrl); + } + + private Dictionary> ParseTxtContent(byte[] zipBytes) + { + var pronosticosAgrupados = new Dictionary>(); + + using var memoryStream = new MemoryStream(zipBytes); + using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read); + + // Buscamos el primer archivo .txt dentro del ZIP + var txtEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)); + if (txtEntry == null) + { + _logger.LogWarning("No se encontró un archivo .txt dentro del ZIP descargado."); + return pronosticosAgrupados; + } + + using var stream = txtEntry.Open(); + using var reader = new StreamReader(stream); + + string? currentStation = null; + List currentForecasts = new List(); + + string? line; + while ((line = reader.ReadLine()) != null) + { + // Si la línea es la de separación, y estábamos procesando una estación, la guardamos. + if (line.Trim().StartsWith("====")) + { + if (currentStation != null && currentForecasts.Any()) + { + pronosticosAgrupados[currentStation] = new List(currentForecasts); + _logger.LogDebug("Estación '{Station}' parseada con {Count} registros.", currentStation, currentForecasts.Count); + } + currentStation = null; + currentForecasts.Clear(); + continue; + } + + // Si no estamos en una estación, buscamos la siguiente + if (currentStation == null) + { + // Una línea de nombre de estación no empieza con espacio + if (!string.IsNullOrWhiteSpace(line) && char.IsLetter(line[0])) + { + currentStation = line.Trim(); + } + continue; + } + + // Si estamos dentro de una estación, intentamos parsear la línea de datos + try + { + // Saltamos líneas vacías o de cabecera + if (string.IsNullOrWhiteSpace(line) || !char.IsDigit(line.Trim()[0])) continue; + + var pronostico = ParseForecastLine(currentStation, line); + currentForecasts.Add(pronostico); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Se omitió una línea de pronóstico inválida para la estación '{Station}': '{Line}'", currentStation, line); + } + } + + return pronosticosAgrupados; + } + + private Pronostico ParseForecastLine(string station, string line) + { + // Parseo de fecha y hora. Ej: "25/JUL/2025 00Hs." + var fullDateString = line.Substring(2, 18).Trim(); + var datePart = fullDateString.Substring(0, 11); + var hourPart = fullDateString.Substring(12, 2); + // Creamos un string de fecha compatible: "25/JUL/2025 00" + var parsableDateString = $"{datePart} {hourPart}"; + // Usamos CultureInfo para que entienda "JUL" en español + var culture = new CultureInfo("es-AR"); + var fechaHora = DateTime.ParseExact(parsableDateString, "dd/MMM/yyyy HH", culture); + + // Parseo de valores numéricos de ancho fijo + var temperatura = decimal.Parse(line.Substring(28, 10).Trim(), CultureInfo.InvariantCulture); + var vientoData = line.Substring(41, 12).Split('|', StringSplitOptions.TrimEntries); + var vientoDir = int.Parse(vientoData[0]); + var vientoVel = int.Parse(vientoData[1]); + var precipitacion = decimal.Parse(line.Substring(59, 10).Trim(), CultureInfo.InvariantCulture); + + return new Pronostico + { + Estacion = station, + FechaHora = fechaHora, + TemperaturaC = temperatura, + VientoDirGrados = vientoDir, + VientoKmh = vientoVel, + PrecipitacionMm = precipitacion + }; + } + } +} \ No newline at end of file diff --git a/src/Clima.Infrastructure/Persistence/IDbConnectionFactory.cs b/src/Clima.Infrastructure/Persistence/IDbConnectionFactory.cs new file mode 100644 index 0000000..ebf235a --- /dev/null +++ b/src/Clima.Infrastructure/Persistence/IDbConnectionFactory.cs @@ -0,0 +1,15 @@ +using System.Data; + +namespace Clima.Infrastructure.Persistence +{ + /// + /// Define una fábrica para crear conexiones a la base de datos. + /// + public interface IDbConnectionFactory + { + /// + /// Crea y devuelve una nueva instancia de IDbConnection. + /// + IDbConnection CreateConnection(); + } +} \ No newline at end of file diff --git a/src/Clima.Infrastructure/Persistence/Repositories/IPronosticoRepository.cs b/src/Clima.Infrastructure/Persistence/Repositories/IPronosticoRepository.cs index 72d67b0..02805b4 100644 --- a/src/Clima.Infrastructure/Persistence/Repositories/IPronosticoRepository.cs +++ b/src/Clima.Infrastructure/Persistence/Repositories/IPronosticoRepository.cs @@ -2,9 +2,9 @@ using Clima.Core.Entities; namespace Clima.Infrastructure.Persistence.Repositories { - public interface IPronosticoRepository - { - Task> ObtenerPorEstacionAsync(string nombreEstacion); - Task ReemplazarPronosticosPorEstacionAsync(string nombreEstacion, IEnumerable pronosticos); - } + public interface IPronosticoRepository + { + Task> ObtenerPorEstacionAsync(string nombreEstacion); + Task ReemplazarPronosticosPorEstacionAsync(string nombreEstacion, IEnumerable pronosticos); + } } \ No newline at end of file diff --git a/src/Clima.Infrastructure/Persistence/Repositories/PronosticoRepository.cs b/src/Clima.Infrastructure/Persistence/Repositories/PronosticoRepository.cs index 8ea1667..fbcfe25 100644 --- a/src/Clima.Infrastructure/Persistence/Repositories/PronosticoRepository.cs +++ b/src/Clima.Infrastructure/Persistence/Repositories/PronosticoRepository.cs @@ -5,44 +5,44 @@ using System.Data; namespace Clima.Infrastructure.Persistence.Repositories { - public class PronosticoRepository : IPronosticoRepository + public class PronosticoRepository : IPronosticoRepository + { + private readonly IDbConnectionFactory _dbConnectionFactory; + + public PronosticoRepository(IDbConnectionFactory dbConnectionFactory) { - private readonly IDbConnectionFactory _dbConnectionFactory; + _dbConnectionFactory = dbConnectionFactory; + } - public PronosticoRepository(IDbConnectionFactory dbConnectionFactory) - { - _dbConnectionFactory = dbConnectionFactory; - } + public async Task> ObtenerPorEstacionAsync(string nombreEstacion) + { + using var connection = _dbConnectionFactory.CreateConnection(); + const string sql = "SELECT * FROM Pronosticos WHERE Estacion = @NombreEstacion ORDER BY FechaHora ASC;"; + return await connection.QueryAsync(sql, new { NombreEstacion = nombreEstacion }); + } - public async Task> ObtenerPorEstacionAsync(string nombreEstacion) - { - using var connection = _dbConnectionFactory.CreateConnection(); - const string sql = "SELECT * FROM Pronosticos WHERE Estacion = @NombreEstacion ORDER BY FechaHora ASC;"; - return await connection.QueryAsync(sql, new { NombreEstacion = nombreEstacion }); - } + public async Task ReemplazarPronosticosPorEstacionAsync(string nombreEstacion, IEnumerable pronosticos) + { + using var connection = _dbConnectionFactory.CreateConnection(); + connection.Open(); + using var transaction = connection.BeginTransaction(); + try + { + const string deleteSql = "DELETE FROM Pronosticos WHERE Estacion = @NombreEstacion;"; + await connection.ExecuteAsync(deleteSql, new { NombreEstacion = nombreEstacion }, transaction); - public async Task ReemplazarPronosticosPorEstacionAsync(string nombreEstacion, IEnumerable pronosticos) - { - using var connection = _dbConnectionFactory.CreateConnection(); - connection.Open(); - using var transaction = connection.BeginTransaction(); - try - { - const string deleteSql = "DELETE FROM Pronosticos WHERE Estacion = @NombreEstacion;"; - await connection.ExecuteAsync(deleteSql, new { NombreEstacion = nombreEstacion }, transaction); - - const string insertSql = @" + const string insertSql = @" INSERT INTO Pronosticos (Estacion, FechaHora, TemperaturaC, VientoDirGrados, VientoKmh, PrecipitacionMm) VALUES (@Estacion, @FechaHora, @TemperaturaC, @VientoDirGrados, @VientoKmh, @PrecipitacionMm);"; - await connection.ExecuteAsync(insertSql, pronosticos, transaction); - - transaction.Commit(); - } - catch - { - transaction.Rollback(); - throw; - } - } + await connection.ExecuteAsync(insertSql, pronosticos, transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } } + } } \ No newline at end of file diff --git a/src/Clima.Infrastructure/Persistence/SqlConnectionFactory.cs b/src/Clima.Infrastructure/Persistence/SqlConnectionFactory.cs new file mode 100644 index 0000000..6304e89 --- /dev/null +++ b/src/Clima.Infrastructure/Persistence/SqlConnectionFactory.cs @@ -0,0 +1,28 @@ +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; +using System.Data; + +namespace Clima.Infrastructure.Persistence +{ + /// + /// Implementación de IDbConnectionFactory para Microsoft SQL Server. + /// + public class SqlConnectionFactory : IDbConnectionFactory + { + private readonly string _connectionString; + + public SqlConnectionFactory(IConfiguration configuration) + { + // Lee la cadena de conexión desde la configuración (appsettings.json, user secrets, etc.) + _connectionString = configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("La cadena de conexión 'DefaultConnection' no fue encontrada en la configuración."); + } + + public IDbConnection CreateConnection() + { + // Crea una nueva conexión de SQL Server. + // Dapper se encargará de abrirla y cerrarla automáticamente. + return new SqlConnection(_connectionString); + } + } +} \ No newline at end of file diff --git a/src/Clima.Worker/Program.cs b/src/Clima.Worker/Program.cs index e96a966..99d7576 100644 --- a/src/Clima.Worker/Program.cs +++ b/src/Clima.Worker/Program.cs @@ -1,7 +1,43 @@ +using Clima.Infrastructure.DataFetchers; +using Clima.Infrastructure.Persistence; +using Clima.Infrastructure.Persistence.Repositories; using Clima.Worker; +using Polly; +using Polly.Extensions.Http; -var builder = Host.CreateApplicationBuilder(args); -builder.Services.AddHostedService(); +IHost host = Host.CreateDefaultBuilder(args) + .ConfigureServices((hostContext, services) => + { + IConfiguration configuration = hostContext.Configuration; -var host = builder.Build(); -host.Run(); + // --- Registro de Servicios --- + + // 1. Infraestructura de Persistencia + services.AddSingleton(); + services.AddScoped(); + + // 2. Fetcher de Datos + services.AddScoped(); + + // 3. Cliente HTTP con política de reintentos + services.AddHttpClient("SmnApiClient") + .AddPolicyHandler(GetRetryPolicy()); + + // 4. Servicio de Fondo principal + services.AddHostedService(); + }) + .Build(); + +/// +/// Define una política de reintentos para las llamadas HTTP. +/// Si una petición falla por un error transitorio, se reintentará hasta 3 veces +/// con una espera exponencial entre cada intento. +/// +static IAsyncPolicy GetRetryPolicy() +{ + return HttpPolicyExtensions + .HandleTransientHttpError() // Maneja errores de red comunes + .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); +} + +await host.RunAsync(); \ No newline at end of file diff --git a/src/Clima.Worker/SmnSyncService.cs b/src/Clima.Worker/SmnSyncService.cs new file mode 100644 index 0000000..8ecb1d0 --- /dev/null +++ b/src/Clima.Worker/SmnSyncService.cs @@ -0,0 +1,53 @@ +using Clima.Infrastructure.DataFetchers; + +namespace Clima.Worker +{ + public class SmnSyncService : BackgroundService + { + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + + public SmnSyncService(ILogger logger, IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Servicio de Sincronización del SMN iniciado."); + + // Ejecutamos una vez al inicio + await RunSync(stoppingToken); + + // Luego, se ejecuta cada 3 horas. + using var timer = new PeriodicTimer(TimeSpan.FromHours(3)); + + while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) + { + await RunSync(stoppingToken); + } + } + + private async Task RunSync(CancellationToken stoppingToken) + { + if (stoppingToken.IsCancellationRequested) return; + + _logger.LogInformation("Iniciando ciclo de sincronización de datos del SMN a las: {time}", DateTimeOffset.Now); + + using var scope = _serviceProvider.CreateScope(); + var fetcher = scope.ServiceProvider.GetRequiredService(); + + var (success, message) = await fetcher.FetchDataAsync(); + + if (success) + { + _logger.LogInformation("Ciclo de sincronización completado: {message}", message); + } + else + { + _logger.LogError("Ciclo de sincronización falló: {message}", message); + } + } + } +} \ No newline at end of file diff --git a/src/Clima.Worker/appsettings.json b/src/Clima.Worker/appsettings.json index b2dcdb6..9f53faf 100644 --- a/src/Clima.Worker/appsettings.json +++ b/src/Clima.Worker/appsettings.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.Hosting.Lifetime": "Information" } + }, + "ConnectionStrings": { + "DefaultConnection": "" } }