ProyectoGrupal/dolibarr-bff/DoliMiddlewareApi/Program.cs

316 lines
12 KiB
C#
Raw Permalink Normal View History

using System.Text;
using System.Threading.RateLimiting;
using DoliMiddlewareApi.Exceptions;
using DoliMiddlewareApi.Services;
using DoliMiddlewareApi.Services.Auth;
using DoliMiddlewareApi.Services.Clients;
using DoliMiddlewareApi.Services.Notifications;
using DoliMiddlewareApi.Services.VeriFactu;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
// =========================================
// PROGRAM.CS - CONFIGURACIÓN Y DEPENDENCIAS
// =========================================
var builder = WebApplication.CreateBuilder(args);
if (string.IsNullOrEmpty(builder.Configuration["Jwt:Secret"]))
throw new InvalidOperationException(
"JWT Secret is required. Set 'Jwt__Secret' environment variable, 'Jwt:Secret' in appsettings, or use 'dotnet user-secrets set Jwt:Secret <value>'.");
// =========================================
// 1. CONFIGURACIÓN DE SERVICIOS ASP.NET CORE
// =========================================
// Controllers + JSON (camelCase + enums as strings)
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
});
// JWT Standard (ASP.NET auto-validation)
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "DoliMiddleware",
ValidAudience = builder.Configuration["Jwt:Audience"] ?? "DoliClients",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
};
});
builder.Services.AddAuthorization();
// CORS: Permitir cualquier origen (demo)
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowVueApp", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// Swagger/OpenAPI
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "DoliMiddlewareApi",
Version = "1.0",
Description = "BFF API for Dolibarr ERP - Middleware between frontend and Dolibarr"
});
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Scheme = "bearer",
BearerFormat = "JWT",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.Http,
Description = "Enter your JWT Bearer token (obtained from POST /api/Auth/login)"
});
options.AddSecurityRequirement(document => new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference("Bearer", document)] = []
});
});
// Health checks (verifica que el BFF responde)
builder.Services.AddHealthChecks();
// Rate limiting: proteger login contra brute force
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var path = context.Request.Path.Value;
if (path != null && path.StartsWith("/api/Auth/login", StringComparison.OrdinalIgnoreCase))
{
return RateLimitPartition.GetSlidingWindowLimiter("login", _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 2,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
}
return RateLimitPartition.GetNoLimiter("default");
});
});
// 2. DOLIBARR HTTP CLIENT
// =========================================
// HttpClient named para Dolibarr (configurado solo con BaseAddress)
// DolibarrApiClient se crea manual porque necesita HttpClient + TokenCacheService
//
// POR QUÉ NO usar AddHttpClient<TClient>() directamente?
// --------------------------------------------------------
// HttpClient es COMPARTIDO entre todos los usuarios
// Si configuramos el token ahí, el Usuario A usaría el token del Usuario B
// Solución: TokenCacheService obtiene el token del USUARIO ACTUAL (del JWT)
// factory.CreateClient("Dolibarr") devuelve el MISMO HttpClient reusado
// No se crea un cliente por request, los headers son por request (thread-safe)
// =========================================
builder.Services.AddHttpClient("Dolibarr", client =>
{
var baseUrl = builder.Configuration["Dolibarr:ApiUrl"]!;
if (!baseUrl.EndsWith('/')) baseUrl += '/';
client.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddHttpClient("VeriFactu", client =>
{
var baseUrl = builder.Configuration["VeriFactu:ApiUrl"] ?? "http://localhost:6789/";
if (!baseUrl.EndsWith('/')) baseUrl += '/';
client.BaseAddress = new Uri(baseUrl);
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.AddScoped<IDolibarrApiClient>(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
var client = factory.CreateClient("Dolibarr");
var tokenCacheService = sp.GetRequiredService<DolibarrTokenCacheService>();
var config = sp.GetRequiredService<IConfiguration>();
return new DolibarrApiClient(client, tokenCacheService, config);
});
// =========================================
// 3. REGISTRO DE SERVICIOS (DEPENDENCIAS)
// =========================================
// Servicio de negocio (facturas)
builder.Services.AddScoped<InvoiceService>();
// Servicio de negocio (clientes)
builder.Services.AddScoped<ClientService>();
// Servicio de negocio (contactos)
builder.Services.AddScoped<ContactService>();
// Servicio de negocio (documentos)
builder.Services.AddScoped<DocumentService>();
// Servicio de negocio (setup/diccionarios)
builder.Services.AddScoped<SetupService>();
// Cliente de VeriFactu MidAPI
builder.Services.AddScoped<VeriFactuApiClient>(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
return new VeriFactuApiClient(factory.CreateClient("VeriFactu"));
});
// Servicio de negocio (banco)
builder.Services.AddScoped<BankService>();
// Servicio de facturas de proveedores
builder.Services.AddScoped<SupplierInvoiceService>();
// Servicio de aplicación (orquesta login + cache)
builder.Services.AddScoped<AuthApplicationService>();
// Servicio de autenticación con Dolibarr
builder.Services.AddScoped<DolibarrAuthService>();
// Servicio de cache de tokens de Dolibarr
builder.Services.AddScoped<DolibarrTokenCacheService>();
// Generador de JWT - Singleton porque es stateless
builder.Services.AddSingleton<JwtTokenProvider>();
// Cache en memoria para tokens (IMemoryCache)
builder.Services.AddMemoryCache();
// HttpContext accessor para acceder a User.Claims en servicios
builder.Services.AddHttpContextAccessor();
builder.Services.AddHttpClient("Webhook", c => c.Timeout = TimeSpan.FromSeconds(8));
// =========================================
// NOTIFICACIONES — Webhook (Teams / Slack)
// Configura Notifications:WebhookUrl en appsettings o variable de entorno
// Si no está configurado, las notificaciones se ignoran silenciosamente
// WebhookSettings es singleton para permitir actualización en tiempo de ejecución
// =========================================
builder.Services.AddSingleton<WebhookSettings>();
builder.Services.AddScoped<INotificationService, WebhookNotificationService>();
var app = builder.Build();
// =========================================
// 4. GLOBAL EXCEPTION HANDLER
// =========================================
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exceptionHandler = context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionHandler?.Error;
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
var env = context.RequestServices.GetRequiredService<IWebHostEnvironment>();
// Clasificar el tipo de error
var (statusCode, title, detail, isExpectedError) = exception switch
{
NotFoundException notFound => (StatusCodes.Status404NotFound, "Not Found", notFound.Message, true),
UnauthorizedException => (StatusCodes.Status401Unauthorized, "Unauthorized", "Invalid API credentials", true),
ForbiddenException forbidden => (StatusCodes.Status403Forbidden, "Forbidden", forbidden.Message, true),
BadRequestException badRequest => (StatusCodes.Status400BadRequest, "Bad Request", badRequest.Message, true),
ApiException apiEx => (StatusCodes.Status500InternalServerError, "External Service Error",
env.IsDevelopment() ? apiEx.Message : "The external service is temporarily unavailable", false),
_ => (StatusCodes.Status500InternalServerError, "Internal Server Error",
env.IsDevelopment()
? exception?.Message ?? "An unexpected error occurred"
: "An unexpected error occurred. Please try again later.", false)
};
// Loguear siempre (crítico para debugging)
if (isExpectedError)
{
logger.LogWarning(exception,
"Expected error: {ExceptionType} | Path: {Path} | Message: {Message}",
exception?.GetType().Name, context.Request.Path, exception?.Message);
}
else
{
logger.LogError(exception,
"UNHANDLED EXCEPTION: {ExceptionType} | Path: {Path} | Message: {Message} | StackTrace: {StackTrace}",
exception?.GetType().Name, context.Request.Path, exception?.Message, exception?.StackTrace);
}
// Construir respuesta ProblemDetails (RFC 7807)
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/problem+json";
var problemDetails = new Microsoft.AspNetCore.Mvc.ProblemDetails
{
Status = statusCode,
Title = title,
Detail = detail,
Instance = context.Request.Path
};
// En desarrollo añadir info extra para debugging
if (env.IsDevelopment() && !isExpectedError)
{
problemDetails.Extensions["exceptionType"] = exception?.GetType().Name;
problemDetails.Extensions["stackTrace"] = exception?.StackTrace;
if (exception?.InnerException != null)
{
problemDetails.Extensions["innerException"] = exception.InnerException.Message;
problemDetails.Extensions["innerStackTrace"] = exception.InnerException.StackTrace;
}
}
await context.Response.WriteAsJsonAsync(problemDetails);
});
});
// =========================================
// 5. PIPELINE ASP.NET CORE
// =========================================
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "DoliMiddlewareApi v1");
});
// HTTPS redirect solo si ForceHttps=true (nunca en Docker)
if (bool.TryParse(builder.Configuration["Dolibarr:ForceHttps"], out var forceHttps) && forceHttps)
app.UseHttpsRedirection();
app.UseCors("AllowVueApp");
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
app.Run();