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 <noreply@anthropic.com>
This commit is contained in:
Andrés Eduardo García Márquez 2026-01-07 22:59:56 -05:00
parent a7dde52e02
commit 2b323adcb4
4 changed files with 360 additions and 0 deletions

View File

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Application\Application.csproj" />
<ProjectReference Include="..\Adapters\Driving\Api\Adapters.Driving.Api.csproj" />
<ProjectReference Include="..\Adapters\Driven\Persistence\Adapters.Driven.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="dotenv.net" Version="*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="*" />
<PackageReference Include="Serilog.AspNetCore" Version="*" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="*" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="*" />
<PackageReference Include="Serilog.Expressions" Version="*" />
<PackageReference Include="Serilog.Sinks.File" Version="*" />
</ItemGroup>
</Project>

271
src/backend/Host/Program.cs Normal file
View File

@ -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<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
["application/json", "application/graphql-response+json"]);
});
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
options.Level = CompressionLevel.Fastest);
builder.Services.Configure<GzipCompressionProviderOptions>(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<AppDbContext>("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<AppDbContext>();
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();
}

View File

@ -0,0 +1,5 @@
{
"GraphQL": {
"EnableIntrospection": true
}
}

View File

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