From 2b323adcb4b4d23d06a733ce1643f887ae01512e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Eduardo=20Garc=C3=ADa=20M=C3=A1rquez?= Date: Wed, 7 Jan 2026 22:59:56 -0500 Subject: [PATCH] feat(host): add composition root and API configuration - Program.cs with dependency injection setup - Database connection retry logic with detailed logging - Serilog structured logging configuration - CORS configuration from environment variables - Response compression (Brotli + Gzip) - Rate limiting for GraphQL endpoint - Health checks with database verification - OWASP security headers middleware - Output caching for read-heavy operations Co-Authored-By: Claude Opus 4.5 --- src/backend/Host/Host.csproj | 29 ++ src/backend/Host/Program.cs | 271 ++++++++++++++++++ src/backend/Host/appsettings.Development.json | 5 + src/backend/Host/appsettings.json | 55 ++++ 4 files changed, 360 insertions(+) create mode 100644 src/backend/Host/Host.csproj create mode 100644 src/backend/Host/Program.cs create mode 100644 src/backend/Host/appsettings.Development.json create mode 100644 src/backend/Host/appsettings.json diff --git a/src/backend/Host/Host.csproj b/src/backend/Host/Host.csproj new file mode 100644 index 0000000..0a693e5 --- /dev/null +++ b/src/backend/Host/Host.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/src/backend/Host/Program.cs b/src/backend/Host/Program.cs new file mode 100644 index 0000000..bbc5ea3 --- /dev/null +++ b/src/backend/Host/Program.cs @@ -0,0 +1,271 @@ +using Adapters.Driven.Persistence; +using Adapters.Driven.Persistence.Context; +using Adapters.Driving.Api.Extensions; +using Application; +using dotenv.net; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Serilog; +using System.IO.Compression; +using System.Text.Json; +using System.Threading.RateLimiting; + +// Load .env file (searches up to root directory) +DotEnv.Load(options: new DotEnvOptions( + probeForEnv: true, + probeLevelsToSearch: 5 +)); + +// Configure Serilog from appsettings + environment variables +Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", optional: true) + .AddEnvironmentVariables() + .Build()) + .CreateLogger(); + +try +{ + Log.Information("Starting application"); + + var builder = WebApplication.CreateBuilder(args); + + // Add environment variables to configuration + builder.Configuration.AddEnvironmentVariables(); + + // Build connection string from individual env vars if not provided directly + var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); + if (string.IsNullOrEmpty(connectionString)) + { + var dbHost = Environment.GetEnvironmentVariable("DB_HOST") ?? "localhost"; + var dbPort = Environment.GetEnvironmentVariable("DB_PORT") ?? "1433"; + var dbName = Environment.GetEnvironmentVariable("DB_NAME") ?? "StudentEnrollment"; + var dbUser = Environment.GetEnvironmentVariable("DB_USER") ?? "sa"; + var dbPassword = Environment.GetEnvironmentVariable("DB_PASSWORD") ?? ""; + + connectionString = $"Server={dbHost},{dbPort};Database={dbName};User Id={dbUser};Password={dbPassword};TrustServerCertificate=True"; + builder.Configuration["ConnectionStrings:DefaultConnection"] = connectionString; + } + + builder.Host.UseSerilog(); + + // Add services + builder.Services.AddApplication(); + builder.Services.AddPersistence(builder.Configuration); + builder.Services.AddGraphQLApi(); + + // Response Compression (Brotli preferred, then Gzip) + builder.Services.AddResponseCompression(options => + { + options.EnableForHttps = true; + options.Providers.Add(); + options.Providers.Add(); + options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat( + ["application/json", "application/graphql-response+json"]); + }); + + builder.Services.Configure(options => + options.Level = CompressionLevel.Fastest); + + builder.Services.Configure(options => + options.Level = CompressionLevel.Fastest); + + // CORS (configurable via CORS_ORIGINS env var, comma-separated) + var corsOrigins = Environment.GetEnvironmentVariable("CORS_ORIGINS")?.Split(',', StringSplitOptions.RemoveEmptyEntries) + ?? ["http://localhost:4200"]; + builder.Services.AddCors(options => + { + options.AddDefaultPolicy(policy => + policy.WithOrigins(corsOrigins) + .AllowAnyHeader() + .AllowAnyMethod()); + }); + + // Output caching for read-heavy operations + builder.Services.AddOutputCache(options => + { + options.AddBasePolicy(policy => policy.Expire(TimeSpan.FromSeconds(10))); + options.AddPolicy("Subjects", policy => policy.Expire(TimeSpan.FromMinutes(5))); + options.AddPolicy("Professors", policy => policy.Expire(TimeSpan.FromMinutes(5))); + }); + + // Health checks + builder.Services.AddHealthChecks() + .AddDbContextCheck("database"); + + // Rate limiting (DoS prevention) + builder.Services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddFixedWindowLimiter("graphql", limiter => + { + limiter.PermitLimit = 100; + limiter.Window = TimeSpan.FromMinutes(1); + limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + limiter.QueueLimit = 10; + }); + options.AddFixedWindowLimiter("mutations", limiter => + { + limiter.PermitLimit = 30; + limiter.Window = TimeSpan.FromMinutes(1); + limiter.QueueLimit = 5; + }); + }); + + var app = builder.Build(); + + // Verify database connection and apply migrations + await VerifyDatabaseConnectionAsync(app); + + async Task VerifyDatabaseConnectionAsync(WebApplication app) + { + var maxRetries = 10; + var delay = TimeSpan.FromSeconds(5); + + for (int attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + Log.Information("Attempting database connection (attempt {Attempt}/{MaxRetries})...", attempt, maxRetries); + + // Try to apply migrations - this will create the DB if it doesn't exist + Log.Information("Applying database migrations (this will create DB if needed)..."); + await db.Database.MigrateAsync(); + Log.Information("Database migrations applied successfully"); + + // Verify connection works after migrations + if (await db.Database.CanConnectAsync()) + { + Log.Information("Database connection verified successfully"); + return; + } + } + catch (Exception ex) + { + Log.Error(ex, "Database operation failed (attempt {Attempt}/{MaxRetries}): {ErrorMessage}", + attempt, maxRetries, ex.Message); + + if (attempt == maxRetries) + { + Log.Fatal("Could not establish database connection after {MaxRetries} attempts. " + + "Please verify: DB_HOST={DbHost}, DB_PORT={DbPort}, DB_NAME={DbName}, DB_USER={DbUser}", + maxRetries, + Environment.GetEnvironmentVariable("DB_HOST") ?? "localhost", + Environment.GetEnvironmentVariable("DB_PORT") ?? "1433", + Environment.GetEnvironmentVariable("DB_NAME") ?? "StudentEnrollment", + Environment.GetEnvironmentVariable("DB_USER") ?? "sa"); + } + } + + if (attempt < maxRetries) + { + Log.Information("Waiting {Delay} seconds before next connection attempt...", delay.TotalSeconds); + await Task.Delay(delay); + } + } + } + + // Security headers (OWASP recommended) + app.Use(async (context, next) => + { + var headers = context.Response.Headers; + headers.Append("X-Content-Type-Options", "nosniff"); + headers.Append("X-Frame-Options", "DENY"); + headers.Append("X-XSS-Protection", "0"); + headers.Append("Referrer-Policy", "strict-origin-when-cross-origin"); + headers.Append("Permissions-Policy", "geolocation=(), microphone=(), camera=()"); + // CSP: More restrictive in production, relaxed for dev (GraphQL Playground needs unsafe-inline/eval) + var csp = app.Environment.IsDevelopment() + ? "default-src 'self'; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: https:; " + + "font-src 'self'; " + + "connect-src 'self' http://localhost:* https://localhost:*; " + + "frame-ancestors 'none'; " + + "base-uri 'self'; " + + "form-action 'self'" + : "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self'; " + + "img-src 'self' data:; " + + "font-src 'self'; " + + "connect-src 'self'; " + + "frame-ancestors 'none'; " + + "base-uri 'self'; " + + "form-action 'self'"; + headers.Append("Content-Security-Policy", csp); + if (!context.Request.IsHttps && !app.Environment.IsDevelopment()) + { + headers.Append("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + } + await next(); + }); + + // Request logging (structured logs, excludes sensitive data) + app.UseSerilogRequestLogging(options => + { + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); + diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString()); + }; + options.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms"; + }); + + // Middleware order matters for performance + app.UseResponseCompression(); + app.UseCors(); + app.UseRateLimiter(); + app.UseOutputCache(); + + // Health check endpoint with JSON response + app.MapHealthChecks("/health", new HealthCheckOptions + { + ResponseWriter = async (context, report) => + { + context.Response.ContentType = "application/json"; + var result = new + { + status = report.Status.ToString(), + timestamp = DateTime.UtcNow, + checks = report.Entries.Select(e => new + { + name = e.Key, + status = e.Value.Status.ToString(), + description = e.Value.Description, + duration = e.Value.Duration.TotalMilliseconds + }) + }; + await context.Response.WriteAsync(JsonSerializer.Serialize(result, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + })); + } + }); + + // GraphQL endpoint with Banana Cake Pop playground and rate limiting + app.MapGraphQL() + .RequireRateLimiting("graphql") + .WithOptions(new HotChocolate.AspNetCore.GraphQLServerOptions + { + Tool = { Enable = app.Environment.IsDevelopment() } + }); + + await app.RunAsync(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + await Log.CloseAndFlushAsync(); +} diff --git a/src/backend/Host/appsettings.Development.json b/src/backend/Host/appsettings.Development.json new file mode 100644 index 0000000..c84fe47 --- /dev/null +++ b/src/backend/Host/appsettings.Development.json @@ -0,0 +1,5 @@ +{ + "GraphQL": { + "EnableIntrospection": true + } +} diff --git a/src/backend/Host/appsettings.json b/src/backend/Host/appsettings.json new file mode 100644 index 0000000..3f1cc23 --- /dev/null +++ b/src/backend/Host/appsettings.json @@ -0,0 +1,55 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "" + }, + "Serilog": { + "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "HotChocolate": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + }, + { + "Name": "File", + "Args": { + "path": "logs/app-.log", + "rollingInterval": "Day", + "retainedFileCountLimit": 7, + "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + } + ], + "Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"], + "Destructure": [ + { + "Name": "ToMaximumDepth", + "Args": { "maximumDestructuringDepth": 3 } + } + ], + "Filter": [ + { + "Name": "ByExcluding", + "Args": { + "expression": "Contains(@Message, 'password') or Contains(@Message, 'Password') or Contains(@Message, 'secret') or Contains(@Message, 'token') or Contains(@Message, 'connectionString')" + } + } + ] + }, + "AllowedHosts": "*", + "GraphQL": { + "MaxExecutionDepth": 5, + "MaxComplexity": 100 + } +}