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