Files
baya-monorepo/server/src/API/Baya.Web.Api/Program.cs
T
2026-07-27 00:54:22 +03:30

201 lines
7.8 KiB
C#

using System.Diagnostics;
using Baya.Application.Features.Users.Commands.Create;
using Baya.Application.Models.ApiResult;
using Baya.Application.Models.Identity;
using Baya.Application.ServiceConfiguration;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.CrossCutting.Logging;
using Baya.Infrastructure.CrossCutting.ServiceConfiguration;
using Baya.Infrastructure.Identity.Identity.Dtos;
using Baya.Infrastructure.Identity.Jwt;
using Baya.Infrastructure.Identity.ServiceConfiguration;
using Baya.Infrastructure.Monitoring.Configurations;
using Baya.Infrastructure.Persistence.ServiceConfiguration;
using Baya.SharedKernel.Extensions;
using Baya.Web.Api.Configuration;
using Baya.Web.Plugins.Grpc;
using Baya.WebFramework.Filters;
using Baya.WebFramework.Middlewares;
using Baya.WebFramework.Routing;
using Baya.WebFramework.ServiceConfiguration;
using Baya.WebFramework.Swagger;
using Mapster;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog(LoggingConfiguration.ConfigureLogger);
var configuration = builder.Configuration;
// Fail fast if a load-bearing secret (DB connection, JWE/field-encryption keys) is missing or still a
// committed placeholder — before any service reaches for it. Skipped in the "Testing" environment.
builder.ValidateRequiredSecrets();
Activity.DefaultIdFormat = ActivityIdFormat.W3C;
// HTTPS metadata is required for the token exchange in deployed environments; relaxed for local
// Development and the Testing host, which run over plain HTTP.
var requireHttpsMetadata = !builder.Environment.IsDevelopment() && !builder.Environment.IsEnvironment("Testing");
builder
.ConfigureHealthChecks()
.SetupOpenTelemetry();
builder.Services.Configure<IdentitySettings>(configuration.GetSection(nameof(IdentitySettings)));
var identitySettings = configuration.GetSection(nameof(IdentitySettings)).Get<IdentitySettings>();
builder.Services.AddControllers(options =>
{
options.Conventions.Add(new RouteTokenTransformerConvention(new SnakeCaseParameterTransformer()));
options.Filters.Add(typeof(OkResultAttribute));
options.Filters.Add(typeof(NotFoundResultAttribute));
options.Filters.Add(typeof(ContentResultFilterAttribute));
options.Filters.Add(typeof(ModelStateValidationAttribute));
options.Filters.Add(typeof(BadRequestResultFilterAttribute));
options.Filters.Add(new ProducesResponseTypeAttribute(typeof(ApiResult<Dictionary<string, List<string>>>),
StatusCodes.Status400BadRequest));
options.Filters.Add(new ProducesResponseTypeAttribute(typeof(ApiResult),
StatusCodes.Status401Unauthorized));
options.Filters.Add(new ProducesResponseTypeAttribute(typeof(ApiResult),
StatusCodes.Status403Forbidden));
options.Filters.Add(new ProducesResponseTypeAttribute(typeof(ApiResult),
StatusCodes.Status500InternalServerError));
}).ConfigureApiBehaviorOptions(options =>
{
options.SuppressModelStateInvalidFilter = true;
options.SuppressMapClientErrors = true;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwagger("v1","v1.1");
builder.Services.AddApplicationServices()
.RegisterIdentityServices(identitySettings, requireHttpsMetadata)
.AddPersistenceServices(configuration)
.AddCrossCuttingSeams(configuration)
.AddWebFrameworkServices()
.AddCorsPolicies(configuration)
.AddForwardedHeadersConfiguration(configuration)
.AddRateLimitingPolicies();
// Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login
// without an SMS gateway. refinement-phase-8: the capture bridge runs ONLY while the log-only mock SMS sender is
// selected — once a real gateway (Seams:Sms:Provider) ships, the OTP is delivered over the wire and never logged
// or captured. Nothing here is wired in any other environment.
var smsProvider = configuration["Seams:Sms:Provider"];
var usingMockSms = string.IsNullOrWhiteSpace(smsProvider) || smsProvider.Equals("mock", StringComparison.OrdinalIgnoreCase);
if (builder.Environment.IsDevelopment() && usingMockSms)
builder.Services.AddDevelopmentOtpCapture();
// The IPaymentCaptureSimulator + bookings/convert path is a Development/Testing affordance (b10's real webhook
// confirm supersedes it in production). Re-register the succeeding mock over the production fail-closed stand-in.
if (builder.Environment.IsDevelopment() || builder.Environment.IsEnvironment("Testing"))
builder.Services.AddDevelopmentPaymentCapture();
builder.Services.RegisterValidatorsAsServices();
builder.Services.AddExceptionHandler<ExceptionHandler>();
builder.Services.AddMapster();
TypeAdapterConfig.GlobalSettings.Scan(typeof(UserCreateCommand).Assembly,
typeof(GetRolesDto).Assembly);
#region Plugin Services Configuration
builder.Services.ConfigureGrpcPluginServices(builder.Environment);
#endregion
var app = builder.Build();
// Deploy-time migration one-shot (refinement-phase-7): `dotnet run -- migrate` (or `<binary> migrate`) applies
// EF migrations + the idempotent seeders, then exits. Running DDL as a separate deploy step means normal boots —
// especially concurrent multi-instance start-ups — never race on schema, and the runtime login needs no
// permanent DDL rights.
if (args.Any(a => string.Equals(a, "migrate", StringComparison.OrdinalIgnoreCase)))
{
await app.ApplyMigrationsAsync();
await app.SeedDefaultUsersAsync();
if (app.Environment.IsDevelopment())
{
await app.SeedPaymentGatewaysAsync();
await app.SeedDemoWorldAsync();
await app.SeedDemoLifecycleAsync();
}
return;
}
// Integration tests (WebApplicationFactory, env "Testing") run on in-memory SQLite — the SQL Server
// migrations can't apply there; the test factory does EnsureCreated + seeding itself.
if (!app.Environment.IsEnvironment("Testing"))
{
if (app.Environment.IsDevelopment())
{
// Local convenience: apply migrations + seed on boot. Development-only: a sandbox payment gateway
// (all-zeros merchant id), a demo marketplace (nurses/variants/search rows, customers/patients), and
// the lifecycle demo world (bookings/money/reviews/tickets in every state) so the real-path screens
// aren't empty. None of it belongs in a deployed DB — all are idempotent.
await app.ApplyMigrationsAsync();
await app.SeedDefaultUsersAsync();
await app.SeedPaymentGatewaysAsync();
await app.SeedDemoWorldAsync();
await app.SeedDemoLifecycleAsync();
}
else
{
// Deployed: DDL is the separate `migrate` step above. Boot only *checks* the schema is current (fail fast
// on a pending migration) and seeds idempotent runtime data (roles + any configured break-glass admin).
await app.EnsureSchemaUpToDateAsync();
await app.SeedDefaultUsersAsync();
}
}
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
app.UseExceptionHandler(_=>{});
// First in the pipeline so the resolved client IP (X-Forwarded-For, from a trusted proxy) is in place
// before anything downstream — notably the rate limiter — reads HttpContext.Connection.RemoteIpAddress.
app.UseForwardedHeaders();
app.UseSwaggerAndUi();
app.UseRouting();
// After UseRouting and before the rate limiter / authentication so a pre-flight OPTIONS is answered
// (and not rejected as 429/401) before the browser sends the real cross-origin request.
app.UseCors(CorsServiceExtension.PolicyName);
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.UseMetrics()
.UseHealthChecks();
app.ConfigureGrpcPipeline();
await app.RunAsync();
/// <summary>Exposes the entry point to <c>WebApplicationFactory&lt;Program&gt;</c>-based integration tests.</summary>
public partial class Program;