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:
parent
a7dde52e02
commit
2b323adcb4
|
|
@ -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>
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"GraphQL": {
|
||||||
|
"EnableIntrospection": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue