feat(backend): Complete backend setup with API and Worker services

This commit is contained in:
2025-07-25 13:59:14 -03:00
parent c8d1fe5d8b
commit 9944fc41fd
14 changed files with 474 additions and 84 deletions

View File

@@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>b004094a-ecbf-4bb4-aa67-af634a6090d9</UserSecretsId>
</PropertyGroup>
<ItemGroup>

View File

@@ -0,0 +1,55 @@
using Clima.Core.Entities;
using Clima.Infrastructure.Persistence.Repositories;
using Microsoft.AspNetCore.Mvc;
namespace Clima.Api.Controllers
{
/// <summary>
/// Provee endpoints para acceder a los datos del pronóstico del tiempo.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class WeatherController : ControllerBase
{
private readonly IPronosticoRepository _pronosticoRepository;
private readonly ILogger<WeatherController> _logger;
public WeatherController(
IPronosticoRepository pronosticoRepository,
ILogger<WeatherController> logger)
{
_pronosticoRepository = pronosticoRepository;
_logger = logger;
}
/// <summary>
/// Obtiene el pronóstico de 5 días para una estación meteorológica específica.
/// </summary>
/// <param name="nombreEstacion">El nombre de la estación (ej. "LA_PLATA_AERO").</param>
/// <returns>Una lista de pronósticos ordenados por fecha.</returns>
[HttpGet("{nombreEstacion}")]
[ProducesResponseType(typeof(IEnumerable<Pronostico>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> 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.");
}
}
}
}

View File

@@ -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<IDbConnectionFactory, SqlConnectionFactory>();
// 2. Repositorio
builder.Services.AddScoped<IPronosticoRepository, PronosticoRepository>();
// 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<IMigrationRunner>();
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();

View File

@@ -5,5 +5,8 @@
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": ""
},
"AllowedHosts": "*"
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,8 @@
namespace Clima.Infrastructure.DataFetchers
{
public interface IDataFetcher
{
string SourceName { get; }
Task<(bool Success, string Message)> FetchDataAsync();
}
}

View File

@@ -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
{
/// <summary>
/// Realiza el proceso ETL completo para los datos de pronóstico de 5 días del SMN.
/// </summary>
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<SmnEtlFetcher> _logger;
public SmnEtlFetcher(
IHttpClientFactory httpClientFactory,
IPronosticoRepository pronosticoRepository,
ILogger<SmnEtlFetcher> 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<byte[]> DownloadZipFileAsync()
{
var client = _httpClientFactory.CreateClient("SmnApiClient");
return await client.GetByteArrayAsync(DataUrl);
}
private Dictionary<string, List<Pronostico>> ParseTxtContent(byte[] zipBytes)
{
var pronosticosAgrupados = new Dictionary<string, List<Pronostico>>();
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<Pronostico> currentForecasts = new List<Pronostico>();
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<Pronostico>(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
};
}
}
}

View File

@@ -0,0 +1,15 @@
using System.Data;
namespace Clima.Infrastructure.Persistence
{
/// <summary>
/// Define una fábrica para crear conexiones a la base de datos.
/// </summary>
public interface IDbConnectionFactory
{
/// <summary>
/// Crea y devuelve una nueva instancia de IDbConnection.
/// </summary>
IDbConnection CreateConnection();
}
}

View File

@@ -2,9 +2,9 @@ using Clima.Core.Entities;
namespace Clima.Infrastructure.Persistence.Repositories
{
public interface IPronosticoRepository
{
Task<IEnumerable<Pronostico>> ObtenerPorEstacionAsync(string nombreEstacion);
Task ReemplazarPronosticosPorEstacionAsync(string nombreEstacion, IEnumerable<Pronostico> pronosticos);
}
public interface IPronosticoRepository
{
Task<IEnumerable<Pronostico>> ObtenerPorEstacionAsync(string nombreEstacion);
Task ReemplazarPronosticosPorEstacionAsync(string nombreEstacion, IEnumerable<Pronostico> pronosticos);
}
}

View File

@@ -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<IEnumerable<Pronostico>> 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<Pronostico>(sql, new { NombreEstacion = nombreEstacion });
}
public async Task<IEnumerable<Pronostico>> 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<Pronostico>(sql, new { NombreEstacion = nombreEstacion });
}
public async Task ReemplazarPronosticosPorEstacionAsync(string nombreEstacion, IEnumerable<Pronostico> 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<Pronostico> 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;
}
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using System.Data;
namespace Clima.Infrastructure.Persistence
{
/// <summary>
/// Implementación de IDbConnectionFactory para Microsoft SQL Server.
/// </summary>
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);
}
}
}

View File

@@ -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<Worker>();
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<IDbConnectionFactory, SqlConnectionFactory>();
services.AddScoped<IPronosticoRepository, PronosticoRepository>();
// 2. Fetcher de Datos
services.AddScoped<IDataFetcher, SmnEtlFetcher>();
// 3. Cliente HTTP con política de reintentos
services.AddHttpClient("SmnApiClient")
.AddPolicyHandler(GetRetryPolicy());
// 4. Servicio de Fondo principal
services.AddHostedService<SmnSyncService>();
})
.Build();
/// <summary>
/// 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.
/// </summary>
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError() // Maneja errores de red comunes
.WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}
await host.RunAsync();

View File

@@ -0,0 +1,53 @@
using Clima.Infrastructure.DataFetchers;
namespace Clima.Worker
{
public class SmnSyncService : BackgroundService
{
private readonly ILogger<SmnSyncService> _logger;
private readonly IServiceProvider _serviceProvider;
public SmnSyncService(ILogger<SmnSyncService> 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<IDataFetcher>();
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);
}
}
}
}

View File

@@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"ConnectionStrings": {
"DefaultConnection": ""
}
}