profileShare

rasmusjy / splitapp-backend-microservices

Read-only snapshot

No repository description.

main default branch 501 files Expires Sep 13, 2026, 9:06 AM
Program.cs 11,345 bytes
1 using System.Globalization;
2 using System.Text;
3 using Asp.Versioning;
4 using Asp.Versioning.ApiExplorer;
5 using Microsoft.AspNetCore.Authentication.JwtBearer;
6 using Microsoft.AspNetCore.DataProtection;
7 using Microsoft.AspNetCore.Localization;
8 using Microsoft.AspNetCore.Mvc;
9 using Microsoft.Extensions.Options;
10 using Microsoft.IdentityModel.Tokens;
11 using SplitApp.Modules.Expenses.Infrastructure;
12 using SplitApp.Modules.Trips.Infrastructure;
13 using SplitApp.Shared.Messaging;
14 using SplitApp.WebApp.Application.Messaging;
15 using SplitApp.WebApp.Application.UsersService;
16 using SplitApp.WebApp.Hosting;
17 using Swashbuckle.AspNetCore.SwaggerGen;
18
19 var builder = WebApplication.CreateBuilder(args);
20
21 // MVC + API + view localization. ApplicationParts surface every remaining module's
22 // controllers (Trips, Expenses). The Users module now lives in SplitApp.UsersService.
23 builder.Services
24 .AddControllersWithViews(opts =>
25 {
26 opts.ModelBinderProviders.Insert(0, new InvariantDecimalModelBinderProvider());
27 })
28 .AddApplicationPart(typeof(SplitApp.Modules.Trips.Api.Controllers.TripsController).Assembly)
29 .AddApplicationPart(typeof(SplitApp.Modules.Expenses.Api.Controllers.ExpensesController).Assembly)
30 .AddViewLocalization()
31 .AddDataAnnotationsLocalization(options =>
32 {
33 options.DataAnnotationLocalizerProvider = (type, factory) =>
34 factory.Create(typeof(App.Resources.Views.Shared));
35 });
36
37 builder.Services.AddRazorPages();
38
39 // API versioning
40 builder.Services.AddApiVersioning(options =>
41 {
42 options.ReportApiVersions = true;
43 options.DefaultApiVersion = new ApiVersion(1, 0);
44 options.AssumeDefaultVersionWhenUnspecified = true;
45 options.ApiVersionReader = new UrlSegmentApiVersionReader();
46 }).AddApiExplorer(options =>
47 {
48 options.GroupNameFormat = "'v'VVV";
49 options.SubstituteApiVersionInUrl = true;
50 });
51
52 builder.Services.AddEndpointsApiExplorer();
53 builder.Services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
54 builder.Services.AddSwaggerGen();
55
56 // Per-module DI registration. Users module is no longer in-process — it runs in
57 // SplitApp.UsersService and is reached over HTTP REST + RabbitMQ RPC.
58 builder.Services.AddTripsModule(builder.Configuration);
59 builder.Services.AddExpensesModule(builder.Configuration);
60
61 // JWT bearer auth. Same signing key/issuer/audience as the Users service — tokens
62 // issued there are validated locally here. OnMessageReceived also accepts the JWT from
63 // a `jwt` cookie so the MVC views work without a manual Authorization header.
64 builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
65 .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
66 {
67 options.RequireHttpsMetadata = false;
68 options.SaveToken = false;
69 options.TokenValidationParameters = new TokenValidationParameters
70 {
71 ValidateIssuer = true,
72 ValidateAudience = true,
73 ValidateLifetime = true,
74 ValidateIssuerSigningKey = true,
75 ValidIssuer = builder.Configuration["JWT:Issuer"],
76 ValidAudience = builder.Configuration["JWT:Audience"],
77 IssuerSigningKey = new SymmetricSecurityKey(
78 Encoding.UTF8.GetBytes(builder.Configuration["JWT:Key"]!)),
79 ClockSkew = TimeSpan.Zero,
80 };
81 options.Events = new JwtBearerEvents
82 {
83 OnMessageReceived = ctx =>
84 {
85 if (string.IsNullOrEmpty(ctx.Token)
86 && ctx.Request.Cookies.TryGetValue("jwt", out var cookieJwt)
87 && !string.IsNullOrEmpty(cookieJwt))
88 {
89 ctx.Token = cookieJwt;
90 }
91 return Task.CompletedTask;
92 },
93 };
94 });
95
96 builder.Services.AddAuthorization();
97
98 // DataProtection keys persist to file system so antiforgery + cookie payloads survive
99 // container restarts. Docker compose mounts /app/keys as a named volume.
100 builder.Services.AddDataProtection()
101 .PersistKeysToFileSystem(new DirectoryInfo(builder.Environment.IsDevelopment() ? "./keys" : "/app/keys"))
102 .SetApplicationName("SplitApp.WebApp");
103
104 // Composition-root facade: aggregates the two remaining module DbContexts (Trips + Expenses).
105 // IUserLookup (from messaging) replaces UsersDbContext for hydrating cross-service navs.
106 builder.Services.AddScoped<SplitApp.WebApp.Application.Contracts.IAppUnitOfWork,
107 SplitApp.WebApp.Application.Persistence.AppUnitOfWork>();
108 builder.Services.AddScoped<SplitApp.WebApp.Application.Persistence.CrossModuleNavigationLoader>();
109
110 // Phase 2 BLL services (lifted into WebApp/Application) — use IAppUnitOfWork.
111 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ITripService, SplitApp.WebApp.Application.Services.TripService>();
112 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IExpenseService, SplitApp.WebApp.Application.Services.ExpenseService>();
113 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ISettlementService, SplitApp.WebApp.Application.Services.SettlementService>();
114 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IInvitationService, SplitApp.WebApp.Application.Services.InvitationService>();
115 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IPollService, SplitApp.WebApp.Application.Services.PollService>();
116 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IBudgetCategoryService, SplitApp.WebApp.Application.Services.BudgetCategoryService>();
117 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IWishlistService, SplitApp.WebApp.Application.Services.WishlistService>();
118 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ISplitPresetService, SplitApp.WebApp.Application.Services.SplitPresetService>();
119
120 // Admin BLL services.
121 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IBudgetCategoryAdminService, SplitApp.WebApp.Application.Services.Admin.BudgetCategoryAdminService>();
122 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ICurrencyAdminService, SplitApp.WebApp.Application.Services.Admin.CurrencyAdminService>();
123 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ITripAdminService, SplitApp.WebApp.Application.Services.Admin.TripAdminService>();
124 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IExpenseAdminService, SplitApp.WebApp.Application.Services.Admin.ExpenseAdminService>();
125 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IPollAdminService, SplitApp.WebApp.Application.Services.Admin.PollAdminService>();
126 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IWishlistAdminService, SplitApp.WebApp.Application.Services.Admin.WishlistAdminService>();
127 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISettlementPlanAdminService, SplitApp.WebApp.Application.Services.Admin.SettlementPlanAdminService>();
128 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISettlementPaymentAdminService, SplitApp.WebApp.Application.Services.Admin.SettlementPaymentAdminService>();
129 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISplitPresetAdminService, SplitApp.WebApp.Application.Services.Admin.SplitPresetAdminService>();
130 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ITripParticipantAdminService, SplitApp.WebApp.Application.Services.Admin.TripParticipantAdminService>();
131 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IInvitationAdminService, SplitApp.WebApp.Application.Services.Admin.InvitationAdminService>();
132 builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IAdminStatsService, SplitApp.WebApp.Application.Services.Admin.AdminStatsService>();
133
134 // Messaging: RabbitMQ for inter-service RPC + events. ServiceName "webapp" appears in
135 // queue names so each subscriber gets its own durable queue.
136 builder.Services.AddMessaging(builder.Configuration, serviceName: "webapp");
137 builder.Services.AddIntegrationEventHandler<SplitApp.Shared.Messaging.Integration.Users.UserDeletedEvent, UserDeletedEventHandler>();
138
139 // Typed HttpClient for the Users service. JwtForwardingHandler propagates the inbound
140 // JWT (cookie or Authorization header) on outbound calls.
141 builder.Services.AddHttpContextAccessor();
142 builder.Services.AddTransient<JwtForwardingHandler>();
143 builder.Services.AddHttpClient<IUsersServiceClient, UsersServiceClient>(c =>
144 {
145 c.BaseAddress = new Uri(builder.Configuration["UsersService:BaseUrl"] ?? "http://users-service:8080");
146 c.Timeout = TimeSpan.FromSeconds(30);
147 })
148 .AddHttpMessageHandler<JwtForwardingHandler>();
149
150 // Localization — EN+ET. resx files in Resources/Views/Shared.{resx,et.resx} are
151 // embedded with explicit LogicalName.
152 builder.Services.AddLocalization(options => options.ResourcesPath = "");
153
154 var supportedCultures = (builder.Configuration.GetSection("SupportedCultures").Get<string[]>()
155 ?? new[] { "en", "et" })
156 .Select(c => new CultureInfo(c)).ToArray();
157 builder.Services.Configure<RequestLocalizationOptions>(options =>
158 {
159 options.SupportedCultures = supportedCultures;
160 options.SupportedUICultures = supportedCultures;
161 options.DefaultRequestCulture = new RequestCulture("en", "en");
162 options.SetDefaultCulture("en");
163 options.RequestCultureProviders = new List<IRequestCultureProvider>
164 {
165 new QueryStringRequestCultureProvider(),
166 new CookieRequestCultureProvider(),
167 };
168 });
169
170 // CORS — same wide-open policy as phase 2/3 so external SPA frontends can call /api/v1/*.
171 builder.Services.AddCors(options =>
172 {
173 options.AddPolicy("CorsAllowAll", policy =>
174 {
175 policy
176 .AllowAnyOrigin()
177 .AllowAnyHeader()
178 .AllowAnyMethod()
179 .WithExposedHeaders("X-Version", "X-Version-Created-At");
180 });
181 });
182
183 var app = builder.Build();
184
185 // Per-module pipeline + migration hooks. Users no longer here.
186 if (!app.Environment.IsEnvironment("Testing"))
187 {
188 app.UseTripsModule();
189 app.UseExpensesModule();
190
191 if (app.Configuration.GetValue<bool>("DataInitialization:SeedData"))
192 {
193 SplitApp.WebApp.Hosting.AppDataInit.SeedExampleData(app.Services);
194 }
195 }
196
197 if (app.Environment.IsDevelopment())
198 {
199 app.UseDeveloperExceptionPage();
200 }
201 else
202 {
203 app.UseExceptionHandler("/Home/Error");
204 }
205
206 app.UseSwagger();
207 app.UseSwaggerUI(options =>
208 {
209 var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
210 foreach (var description in provider.ApiVersionDescriptions)
211 {
212 options.SwaggerEndpoint(
213 $"/swagger/{description.GroupName}/swagger.json",
214 description.GroupName.ToUpperInvariant());
215 }
216 });
217
218 app.UseStaticFiles();
219 app.UseHttpsRedirection();
220 app.UseRouting();
221
222 app.UseRequestLocalization(app.Services.GetRequiredService<IOptions<RequestLocalizationOptions>>().Value);
223
224 app.UseCors("CorsAllowAll");
225
226 app.UseAuthentication();
227 app.UseAuthorization();
228
229 app.MapControllerRoute(
230 name: "areas",
231 pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
232 app.MapControllerRoute(
233 name: "default",
234 pattern: "{controller=Home}/{action=Index}/{id?}");
235 app.MapControllers();
236 app.MapRazorPages();
237
238 app.Run();
239
240 public partial class Program;
241