using Azure.Identity; using Api.Data; using Api.Services; using Api.Services.Contracts; using Microsoft.AspNetCore.Diagnostics; using Serilog; using System.Reflection; using Microsoft.EntityFrameworkCore; using Refit; using System.IO; using Swashbuckle.AspNetCore.Annotations; using Api.Data.Repositories; using Api.Data.Repositories.Contracts; using Api.Clients.Api; using Api.Clients.Api.Contracts; using Api.Clients.Ai; using Api.Clients.Ai.Contracts; using CvMatcher.Models.Settings; using Shared.Models.Settings; DotNetEnv.Env.Load(); try { var builder = WebApplication.CreateBuilder(args); var appVersion = Assembly.GetExecutingAssembly() .GetCustomAttribute()? .InformationalVersion ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; builder.Host.UseSerilog((context, services, configuration) => { configuration .ReadFrom.Configuration(context.Configuration) .ReadFrom.Services(services) .Enrich.FromLogContext() .Enrich.WithMachineName() .Enrich.WithEnvironmentName() .Enrich.WithProperty("Service", "cv-matcher-api") .Enrich.WithProperty("AppVersion", appVersion) .WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter()); }); Log.Information("Starting {Service} version {AppVersion}", "cv-matcher-api", appVersion); // -------------------- // Azure Key Vault Configuration // -------------------- var keyVaultUri = builder.Configuration["KeyVault:VaultUri"]; var keyVaultEnabled = builder.Configuration.GetValue("KeyVault:Enabled"); if (keyVaultEnabled && !string.IsNullOrWhiteSpace(keyVaultUri)) { Log.Information("Loading configuration from Azure Key Vault: {VaultUri}", keyVaultUri); try { builder.Configuration.AddAzureKeyVault( new Uri(keyVaultUri), new DefaultAzureCredential()); Log.Information("Azure Key Vault configuration loaded successfully"); } catch (Exception ex) { Log.Warning(ex, "Failed to load Azure Key Vault configuration. Continuing with other configuration sources."); } } else { Log.Information("Azure Key Vault is disabled or not configured"); } builder.Services.Configure(builder.Configuration.GetSection("RagApi")); builder.Services.Configure(builder.Configuration.GetSection("InternalApi")); builder.Services.Configure(builder.Configuration.GetSection("Ai")); builder.Services.Configure(builder.Configuration.GetSection("Matcher")); builder.Services.Configure(builder.Configuration.GetSection("Smtp")); // Register Refit client for the external RAG API and a thin wrapper that implements IRagApiClient builder.Services.AddRefitClient() .ConfigureHttpClient((sp, c) => { var settings = sp.GetRequiredService>().Value; c.BaseAddress = new Uri(settings.BaseUrl.TrimEnd('/') + "/"); if (!string.IsNullOrWhiteSpace(settings.InternalApiKey)) { c.DefaultRequestHeaders.Add("X-Internal-Api-Key", settings.InternalApiKey); } }); builder.Services.AddScoped(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("CvMatcherDb") ?? throw new InvalidOperationException("Connection string 'CvMatcherDb' is missing."))); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => { var xmlFile = (Assembly.GetExecutingAssembly().GetName().Name ?? "cv-matcher-api") + ".xml"; var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); if (File.Exists(xmlPath)) options.IncludeXmlComments(xmlPath); options.EnableAnnotations(); }); var app = builder.Build(); var logger = app.Services.GetRequiredService>(); logger.LogInformation("API starting up..."); logger.LogInformation("Environment: {Environment}", app.Environment.EnvironmentName); // Log all environment variables and configuration settings at startup // Can be controlled via appsettings: "LogEnvironmentOnStartup": true var logEnvironmentOnStartup = app.Configuration.GetValue("LogEnvironmentOnStartup", defaultValue: true); if (logEnvironmentOnStartup) { LogEnvironmentSettings(logger, app.Configuration, app.Environment); } using (var scope = app.Services.CreateScope()) { var repository = scope.ServiceProvider.GetRequiredService(); await repository.InitializeAsync(CancellationToken.None); } app.UseSerilogRequestLogging(options => { options.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms"; options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => { diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme); diagnosticContext.Set("RemoteIP", httpContext.Connection.RemoteIpAddress?.ToString()); diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString()); }; }); app.UseExceptionHandler(errorApp => { errorApp.Run(async context => { var feature = context.Features.Get(); var logger = context.RequestServices.GetRequiredService>(); if (feature?.Error is not null) { logger.LogError(feature.Error, "Unhandled exception in {Service}", "cv-matcher-api"); } context.Response.StatusCode = StatusCodes.Status500InternalServerError; context.Response.ContentType = "application/json"; await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error." }); }); }); app.Use(async (context, next) => { var settings = context.RequestServices.GetRequiredService>().Value; if (settings.RequireApiKey) { var header = context.Request.Headers["X-Internal-Api-Key"].ToString(); if (string.IsNullOrWhiteSpace(settings.ApiKey) || header != settings.ApiKey) { var logger = context.RequestServices.GetRequiredService>(); logger.LogWarning("Rejected unauthorized internal API call. Path={Path}, RemoteIP={RemoteIP}", context.Request.Path, context.Connection.RemoteIpAddress?.ToString()); context.Response.StatusCode = StatusCodes.Status401Unauthorized; await context.Response.WriteAsJsonAsync(new { error = "Unauthorized internal API call." }); return; } } await next(); }); // Swagger (typically only in Development) if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(options => { options.DocumentTitle = "cv-matcher-api"; options.SwaggerEndpoint("/swagger/v1/swagger.json", "cv-matcher-api v1"); options.RoutePrefix = "swagger"; }); } app.MapControllers(); app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "cv-matcher-api", version = appVersion, timeUtc = DateTimeOffset.UtcNow })); Log.Information("Running EfCore DbMigrations if any"); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } Log.Information("{Service} startup complete", "cv-matcher-api"); app.Run(); } catch (Exception ex) { Log.Fatal(ex, "cv-matcher-api terminated unexpectedly"); } finally { Log.Information("Shutting down cv-matcher-api"); Log.CloseAndFlush(); } static void LogEnvironmentSettings(Microsoft.Extensions.Logging.ILogger logger, IConfiguration configuration, IWebHostEnvironment environment) { logger.LogInformation("==================== ENVIRONMENT SETTINGS ===================="); // Environment Information logger.LogInformation("Application Name: {ApplicationName}", environment.ApplicationName); logger.LogInformation("Environment Name: {EnvironmentName}", environment.EnvironmentName); logger.LogInformation("Content Root Path: {ContentRootPath}", environment.ContentRootPath); logger.LogInformation("Web Root Path: {WebRootPath}", environment.WebRootPath); // Environment Variables logger.LogInformation("-------------- Environment Variables --------------"); var envVars = Environment.GetEnvironmentVariables(); var sortedEnvVars = new SortedDictionary(); foreach (System.Collections.DictionaryEntry entry in envVars) { var key = entry.Key?.ToString() ?? string.Empty; var value = entry.Value?.ToString() ?? string.Empty; // Mask sensitive values (passwords, secrets, tokens, keys) but show last 4 characters if (IsSensitiveKey(key)) { value = MaskValueWithLastChars(value); } sortedEnvVars[key] = value; } foreach (var kvp in sortedEnvVars) { logger.LogInformation(" {Key} = {Value}", kvp.Key, kvp.Value); } // Configuration Settings logger.LogInformation("-------------- Configuration Settings --------------"); LogConfigurationRecursive(logger, configuration.GetChildren(), ""); logger.LogInformation("==========================================================="); } static void LogConfigurationRecursive(Microsoft.Extensions.Logging.ILogger logger, IEnumerable sections, string prefix) { foreach (var section in sections) { var key = string.IsNullOrEmpty(prefix) ? section.Key : $"{prefix}:{section.Key}"; if (section.Value != null) { var value = section.Value; // Mask sensitive configuration values but show last 4 characters if (IsSensitiveKey(key)) { value = MaskValueWithLastChars(value); } logger.LogInformation(" {Key} = {Value}", key, value); } // Recurse into child sections if (section.GetChildren().Any()) { LogConfigurationRecursive(logger, section.GetChildren(), key); } } } static bool IsSensitiveKey(string key) { return key.Contains("Password", StringComparison.OrdinalIgnoreCase) || key.Contains("Secret", StringComparison.OrdinalIgnoreCase) || key.Contains("Token", StringComparison.OrdinalIgnoreCase) || key.Contains("Key", StringComparison.OrdinalIgnoreCase) || key.Contains("ConnectionString", StringComparison.OrdinalIgnoreCase); } static string MaskValueWithLastChars(string value) { if (string.IsNullOrEmpty(value)) { return "***NOT SET***"; } // If value is too short, just mask it completely if (value.Length <= 4) { return "***MASKED***"; } // Show last 4 characters var lastChars = value.Substring(value.Length - 4); return $"***MASKED***...{lastChars}"; }