profileShare

rasmusjy / splitapp-backend-clean-onion

Read-only snapshot

No repository description.

main default branch 429 files Expires Sep 13, 2026, 9:06 AM
Program.cs 11,009 bytes
1 using System.Globalization;
2 using System.IdentityModel.Tokens.Jwt;
3 using System.Text;
4 using App.BLL.Services;
5 using App.DAL.EF;
6 using App.DAL.EF.Seeding;
7 using App.Domain.Contracts;
8 using App.Domain.Identity;
9 using Asp.Versioning;
10 using Asp.Versioning.ApiExplorer;
11 using Microsoft.AspNetCore.DataProtection;
12 using Microsoft.AspNetCore.HttpOverrides;
13 using Microsoft.AspNetCore.Identity;
14 using Microsoft.AspNetCore.Localization;
15 using Microsoft.EntityFrameworkCore;
16 using Microsoft.EntityFrameworkCore.Diagnostics;
17 using Microsoft.Extensions.Options;
18 using Microsoft.IdentityModel.Tokens;
19 using Npgsql;
20 using Swashbuckle.AspNetCore.SwaggerGen;
21 using WebApp;
22
23 var builder = WebApplication.CreateBuilder(args);
24
25 // Add services to the container.
26 var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ??
27 throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
28
29 // DAL composition — single entry point from App.DAL.EF (Clean: WebApp doesn't wire DAL internals)
30 builder.Services.AddDalServices(connectionString);
31
32 builder.Services.AddDatabaseDeveloperPageExceptionFilter();
33
34 // BLL - Services
35 builder.Services.AddScoped<ITripService, TripService>();
36 builder.Services.AddScoped<IExpenseService, ExpenseService>();
37 builder.Services.AddScoped<ISettlementService, SettlementService>();
38 builder.Services.AddScoped<IInvitationService, InvitationService>();
39 builder.Services.AddScoped<IPollService, PollService>();
40 builder.Services.AddScoped<IBudgetCategoryService, BudgetCategoryService>();
41 builder.Services.AddScoped<IWishlistService, WishlistService>();
42 builder.Services.AddScoped<ISplitPresetService, SplitPresetService>();
43 builder.Services.AddScoped<App.BLL.Services.Identity.IIdentityService, App.BLL.Services.Identity.IdentityService>();
44
45 // BLL - Admin Services (Clean: admin controllers go through services, not UoW)
46 builder.Services.AddScoped<App.BLL.Services.Admin.IBudgetCategoryAdminService, App.BLL.Services.Admin.BudgetCategoryAdminService>();
47 builder.Services.AddScoped<App.BLL.Services.Admin.ICurrencyAdminService, App.BLL.Services.Admin.CurrencyAdminService>();
48 builder.Services.AddScoped<App.BLL.Services.Admin.ITripAdminService, App.BLL.Services.Admin.TripAdminService>();
49 builder.Services.AddScoped<App.BLL.Services.Admin.IExpenseAdminService, App.BLL.Services.Admin.ExpenseAdminService>();
50 builder.Services.AddScoped<App.BLL.Services.Admin.IPollAdminService, App.BLL.Services.Admin.PollAdminService>();
51 builder.Services.AddScoped<App.BLL.Services.Admin.IWishlistAdminService, App.BLL.Services.Admin.WishlistAdminService>();
52 builder.Services.AddScoped<App.BLL.Services.Admin.ISettlementPlanAdminService, App.BLL.Services.Admin.SettlementPlanAdminService>();
53 builder.Services.AddScoped<App.BLL.Services.Admin.ISettlementPaymentAdminService, App.BLL.Services.Admin.SettlementPaymentAdminService>();
54 builder.Services.AddScoped<App.BLL.Services.Admin.ISplitPresetAdminService, App.BLL.Services.Admin.SplitPresetAdminService>();
55 builder.Services.AddScoped<App.BLL.Services.Admin.ITripParticipantAdminService, App.BLL.Services.Admin.TripParticipantAdminService>();
56 builder.Services.AddScoped<App.BLL.Services.Admin.IInvitationAdminService, App.BLL.Services.Admin.InvitationAdminService>();
57 builder.Services.AddScoped<App.BLL.Services.Admin.IAdminStatsService, App.BLL.Services.Admin.AdminStatsService>();
58
59 builder.Services
60 .AddDataProtection()
61 .PersistKeysToDbContext<AppDbContext>();
62
63 builder.Services.AddIdentity<AppUser, AppRole>(options => options.SignIn.RequireConfirmedAccount = false)
64 .AddDefaultUI()
65 .AddEntityFrameworkStores<AppDbContext>()
66 .AddDefaultTokenProviders();
67
68 JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
69 builder.Services
70 .AddAuthentication()
71 .AddCookie(options => { options.SlidingExpiration = true; })
72 .AddJwtBearer(cfg =>
73 {
74 cfg.RequireHttpsMetadata = false; // TODO: set to true in production!
75 cfg.SaveToken = true;
76 cfg.TokenValidationParameters = new TokenValidationParameters
77 {
78 ValidIssuer = builder.Configuration["JWT:Issuer"],
79 ValidAudience = builder.Configuration["JWT:Audience"],
80 IssuerSigningKey = new SymmetricSecurityKey(
81 Encoding.UTF8.GetBytes(builder.Configuration["JWT:Key"]!)),
82 ClockSkew = TimeSpan.Zero
83 };
84 });
85
86 var supportedCultures = builder.Configuration
87 .GetSection("SupportedCultures")
88 .GetChildren()
89 .Select(x => new CultureInfo(x.Value!))
90 .ToArray();
91
92 builder.Services.Configure<RequestLocalizationOptions>(options =>
93 {
94 options.SupportedCultures = supportedCultures;
95 options.SupportedUICultures = supportedCultures;
96 options.DefaultRequestCulture = new RequestCulture("en", "en");
97 options.SetDefaultCulture("en");
98
99 options.RequestCultureProviders = new List<IRequestCultureProvider>
100 {
101 new QueryStringRequestCultureProvider(),
102 new CookieRequestCultureProvider()
103 };
104 });
105
106 builder.Services.AddCors(options =>
107 {
108 options.AddPolicy("CorsAllowAll", policy =>
109 {
110 policy
111 .AllowAnyOrigin()
112 .AllowAnyHeader()
113 .AllowAnyMethod()
114 .WithExposedHeaders("X-Version", "X-Version-Created-At");
115 });
116 });
117
118 var apiVersioningBuilder = builder.Services.AddApiVersioning(options =>
119 {
120 options.ReportApiVersions = true;
121 options.DefaultApiVersion = new ApiVersion(1, 0);
122 });
123
124 apiVersioningBuilder.AddApiExplorer(options =>
125 {
126 options.GroupNameFormat = "'v'VVV";
127 options.SubstituteApiVersionInUrl = true;
128 });
129
130 builder.Services.AddEndpointsApiExplorer();
131 builder.Services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
132 builder.Services.AddSwaggerGen();
133
134 // ForwardedHeaders — when app is behind a reverse proxy (e.g. Docker + Caddy),
135 // trust X-Forwarded-For / X-Forwarded-Host / X-Forwarded-Proto so Url.Action() generates
136 // links using the external hostname instead of the container IP.
137 builder.Services.Configure<ForwardedHeadersOptions>(options =>
138 {
139 options.ForwardedHeaders = ForwardedHeaders.XForwardedFor
140 | ForwardedHeaders.XForwardedProto
141 | ForwardedHeaders.XForwardedHost;
142 // Proxy runs in an unknown network — trust any proxy
143 options.KnownIPNetworks.Clear();
144 options.KnownProxies.Clear();
145 });
146
147 builder.Services.AddLocalization(options => options.ResourcesPath = "");
148 builder.Services.AddControllersWithViews(options =>
149 {
150 // Fix decimal binding: HTML number inputs always send dot-separated values,
151 // but et-EE locale expects comma. This binder handles both formats.
152 options.ModelBinderProviders.Insert(0, new InvariantDecimalModelBinderProvider());
153 })
154 .AddViewLocalization()
155 .AddDataAnnotationsLocalization();
156
157 // ==============================================
158 var app = builder.Build();
159 // ============================================== PIPELINE ===============================
160 // Skip data initialization in tests — WebApplicationFactory provides its own SQLite DB
161 if (!app.Environment.IsEnvironment("Testing"))
162 {
163 SetupAppData(app, app.Environment, app.Configuration);
164 }
165
166 // MUST be first — process proxy headers before anything else touches Request.Host/Scheme
167 app.UseForwardedHeaders();
168
169 // Configure the HTTP request pipeline.
170 if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing"))
171 {
172 app.UseMigrationsEndPoint();
173 app.UseDeveloperExceptionPage();
174 }
175 else
176 {
177 app.UseExceptionHandler("/Home/Error");
178 app.UseHsts();
179 }
180
181 app.UseHttpsRedirection();
182
183 app.UseRequestLocalization(options: app.Services
184 .GetService<IOptions<RequestLocalizationOptions>>()!.Value);
185
186 app.UseCors("CorsAllowAll");
187
188 app.UseRouting();
189
190 app.UseAuthorization();
191
192 app.UseSwagger();
193 app.UseSwaggerUI(options =>
194 {
195 var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
196 foreach (var description in provider.ApiVersionDescriptions)
197 {
198 options.SwaggerEndpoint(
199 $"/swagger/{description.GroupName}/swagger.json",
200 description.GroupName.ToUpperInvariant()
201 );
202 }
203 });
204
205 app.MapStaticAssets();
206
207 app.MapControllerRoute(
208 name: "areas",
209 pattern: "{area:exists}/{controller=Dashboard}/{action=Index}/{id?}")
210 .WithStaticAssets();
211
212 app.MapControllerRoute(
213 name: "default",
214 pattern: "{controller=Home}/{action=Index}/{id?}")
215 .WithStaticAssets();
216
217 app.MapRazorPages()
218 .WithStaticAssets();
219
220 app.Run();
221
222 return;
223
224 static void SetupAppData(IApplicationBuilder app, IWebHostEnvironment env, IConfiguration configuration)
225 {
226 using var serviceScope = ((IApplicationBuilder)app).ApplicationServices
227 .GetRequiredService<IServiceScopeFactory>()
228 .CreateScope();
229 var logger = serviceScope.ServiceProvider.GetRequiredService<ILogger<IApplicationBuilder>>();
230
231 using var context = serviceScope.ServiceProvider.GetRequiredService<AppDbContext>();
232
233 WaitDbConnection(context, logger);
234
235 using var userManager = serviceScope.ServiceProvider.GetRequiredService<UserManager<AppUser>>();
236 using var roleManager = serviceScope.ServiceProvider.GetRequiredService<RoleManager<AppRole>>();
237
238 if (configuration.GetValue<bool>("DataInitialization:DropDatabase"))
239 {
240 logger.LogWarning("DropDatabase");
241 AppDataInit.DeleteDatabase(context);
242 }
243
244 if (configuration.GetValue<bool>("DataInitialization:MigrateDatabase"))
245 {
246 logger.LogInformation("MigrateDatabase");
247 AppDataInit.MigrateDatabase(context);
248 }
249
250 if (configuration.GetValue<bool>("DataInitialization:SeedIdentity"))
251 {
252 logger.LogInformation("SeedIdentity");
253 AppDataInit.SeedIdentity(userManager, roleManager);
254 }
255
256 if (configuration.GetValue<bool>("DataInitialization:SeedData"))
257 {
258 logger.LogInformation("SeedData");
259 AppDataInit.SeedAppData(context);
260 }
261 }
262
263 static void WaitDbConnection(AppDbContext ctx, ILogger<IApplicationBuilder> logger)
264 {
265 while (true)
266 {
267 try
268 {
269 ctx.Database.OpenConnection();
270 ctx.Database.CloseConnection();
271 return;
272 }
273 catch (Npgsql.PostgresException e)
274 {
275 logger.LogWarning("Checked postgres db connection. Got: {}", e.Message);
276
277 if (e.Message.Contains("does not exist"))
278 {
279 logger.LogWarning("Applying migration, probably db is not there (but server is)");
280 return;
281 }
282
283 logger.LogWarning("Waiting for db connection. Sleep 1 sec");
284 System.Threading.Thread.Sleep(1000);
285 }
286 catch (Exception e)
287 {
288 logger.LogWarning("DB not available yet: {}", e.Message);
289 System.Threading.Thread.Sleep(1000);
290 }
291 }
292 }
293
294 // Exposed for WebApplicationFactory<Program> in App.Tests
295 public partial class Program { }
296