feat(backend): Complete backend setup with API and Worker services
This commit is contained in:
@@ -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>
|
||||
|
||||
55
src/Clima.Api/Controllers/WeatherController.cs
Normal file
55
src/Clima.Api/Controllers/WeatherController.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -5,5 +5,8 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": ""
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
8
src/Clima.Infrastructure/DataFetchers/IDataFetcher.cs
Normal file
8
src/Clima.Infrastructure/DataFetchers/IDataFetcher.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Clima.Infrastructure.DataFetchers
|
||||
{
|
||||
public interface IDataFetcher
|
||||
{
|
||||
string SourceName { get; }
|
||||
Task<(bool Success, string Message)> FetchDataAsync();
|
||||
}
|
||||
}
|
||||
179
src/Clima.Infrastructure/DataFetchers/SmnEtlFetcher.cs
Normal file
179
src/Clima.Infrastructure/DataFetchers/SmnEtlFetcher.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/Clima.Infrastructure/Persistence/IDbConnectionFactory.cs
Normal file
15
src/Clima.Infrastructure/Persistence/IDbConnectionFactory.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/Clima.Infrastructure/Persistence/SqlConnectionFactory.cs
Normal file
28
src/Clima.Infrastructure/Persistence/SqlConnectionFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
53
src/Clima.Worker/SmnSyncService.cs
Normal file
53
src/Clima.Worker/SmnSyncService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,8 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": ""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user