using System.Reflection; using System.Text; using System.Text.Json; using System.Threading.RateLimiting; using GB5Shared.Connection; using GB5Shared.DateConverter; using static GB5Shared.DateConverter.GB5JsonOptions; using GB5Shared.DTO.Framework.CommonConfig; using GB5Shared.GB5CommonFunction; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Telemetry; using GB5Shared.Validation; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Caching.Hybrid; // ------------------------- // Bootstrap // ------------------------- var builder = WebApplication.CreateBuilder(args); // ── Port from config — change "AppPort" in appsettings.json to use any port ── var appPort = builder.Configuration.GetValue("AppPort"); builder.WebHost.UseUrls($"http://0.0.0.0:{appPort}"); // ------------------------- // Console Encoding // ------------------------- Console.OutputEncoding = Encoding.UTF8; // ------------------------- // Controllers Setup // ------------------------- // MVC controllers only — this module has no FastEndpoints, callers authenticate via // X-Api-Key (validated inline by PromotionExchangeBLL), never a GB5 LoginDTO/session, // matching this repo's one consistent file-upload exception (see CLAUDE.md). builder.Services.AddControllers() .AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = null; options.JsonSerializerOptions.DictionaryKeyPolicy = null; options.JsonSerializerOptions.AddGB5Converters(); }); // ------------------------- // Caching // ------------------------- // ApplicationConnection caches resolved connection strings via HybridCache — required // even though this module has no other caching needs of its own. #pragma warning disable EXTEXP0018 builder.Services.AddHybridCache(options => { options.DefaultEntryOptions = new HybridCacheEntryOptions(); options.DisableCompression = false; }); #pragma warning restore EXTEXP0018 builder.Services.AddMemoryCache(); builder.Services.AddDistributedMemoryCache(); // ------------------------- // Application Services // ------------------------- builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // ApplicationConnection resolves PromotionExchangeDatabase's own DatabaseName to a real // connection string via a MSERVERCONFIG lookup in this same central Gb5System DB — the // same mechanism every other module's IQueryExecutor already relies on. builder.Services.Configure( builder.Configuration.GetSection("Gb5SystemDTO")); builder.Services.Configure(options => { options.AllowSynchronousIO = true; }); // ------------------------- // Rate limiting — repeated bad-X-Api-Key attempts (gaps doc #9) // ------------------------- // Callers here authenticate purely via X-Api-Key (no "Login" header, no ClientId — an origin/ // destination environment, possibly not running any GB5 code at all), so GB5Shared's own // GB5RateLimitExtensions (partitioned by ClientId extracted from a "Login" header) doesn't fit // this module's auth model — every caller would collapse into its single "anonymous" bucket. // Partitioned by remote IP instead, same sliding-window/429/structured-body shape as that // shared extension, just with the one partition key that's actually meaningful for an // unauthenticated-until-key-checked, internet-facing endpoint like this one. var rateLimitPermits = builder.Configuration.GetValue("RateLimit:PermitLimit", 20); var rateLimitWindowSeconds = builder.Configuration.GetValue("RateLimit:WindowSeconds", 60); builder.Services.AddRateLimiter(opts => { opts.AddPolicy("promotion-exchange", context => RateLimitPartition.GetSlidingWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new SlidingWindowRateLimiterOptions { PermitLimit = rateLimitPermits, Window = TimeSpan.FromSeconds(rateLimitWindowSeconds), SegmentsPerWindow = 6, QueueLimit = 0, AutoReplenishment = true, })); opts.RejectionStatusCode = 429; opts.OnRejected = async (ctx, token) => { ctx.HttpContext.Response.ContentType = "application/json"; await ctx.HttpContext.Response.WriteAsync( JsonSerializer.Serialize(new { error = $"Rate limit exceeded. Maximum {rateLimitPermits} requests per {rateLimitWindowSeconds}s per caller." }), token); }; }); // ------------------------- // OpenTelemetry // ------------------------- builder.Services.AddGB5Telemetry(builder.Configuration, "GB5-PROMOTIONEXCHANGE"); // ------------------------- // Assembly Scan for DI // ------------------------- var exchangeBLLAssembly = Assembly.Load("PromotionExchangeBLL"); var exchangeDALAssembly = Assembly.Load("PromotionExchangeDAL"); builder.Services.Scan(scan => scan .FromAssemblies(exchangeBLLAssembly, exchangeDALAssembly) .AddClasses(c => c.Where(t => !t.IsAbstract && !t.IsInterface && (t.Namespace == null || !t.Namespace.Contains(".DTO")))) .AsImplementedInterfaces() .WithScopedLifetime()); // ------------------------- // Build App // ------------------------- var app = builder.Build(); // ------------------------- // Middleware // ------------------------- app.UseMiddleware(); app.UseRateLimiter(); app.MapControllers(); // ------------------------- // Test Endpoint // ------------------------- app.MapGet("/", () => "Hello from GB5PromotionExchange (.NET 9)"); // ------------------------- // HTTPS & Authorization // ------------------------- app.UseAuthorization(); // ------------------------- // Run the Application // ------------------------- app.Run();