using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Polly; using Polly.Bulkhead; using System.Text; using System.Text.Json; using ZymonicGateway.Contracts; using ZymonicGateway.Middleware; using ZymonicGateway.Options; using ZymonicGateway.Services; using ZymonicServices; const string gatewayConfigOption = "gateway-config"; const string defaultGatewayConfigPath = "zymonic-gateway.json"; var startupConfiguration = new ConfigurationBuilder() .AddCommandLine(args) .Build(); var gatewayConfigPath = startupConfiguration[gatewayConfigOption] ?? defaultGatewayConfigPath; var resolvedGatewayConfigPath = Path.GetFullPath(gatewayConfigPath); var builder = WebApplication.CreateBuilder(args); builder.Configuration.Sources.Clear(); builder.Configuration .AddJsonFile(gatewayConfigPath, optional: false, reloadOnChange: true) .AddEnvironmentVariables(prefix: "ZYMONIC_GATEWAY_") .AddCommandLine(args); builder.Logging.ClearProviders(); builder.Logging.AddConsole(); builder.Services.Configure(builder.Configuration.GetSection("ZymonicGateway")); builder.Services.AddSingleton(); builder.Services.AddSingleton(serviceProvider => new ZymonicLogger(serviceProvider.GetRequiredService>())); builder.Services.AddSingleton(serviceProvider => { var options = serviceProvider.GetRequiredService>().Value; var dbPath = Path.GetFullPath(options.DatabasePath); var dbOptions = new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath}") .Options; return new ZymonicDbContextFactory(dbOptions); }); builder.Services.AddSingleton(serviceProvider => new ZymonicSettings( serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService())); builder.Services.AddSingleton(); builder.Services.AddSingleton(serviceProvider => { var options = serviceProvider.GetRequiredService>().Value; return Policy.BulkheadAsync(Math.Max(1, options.QueueRunner.MaxParallelRequests), Math.Max(1, options.QueueRunner.MaxParallelRequests)); }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); var app = builder.Build(); app.Logger.LogInformation("Loaded ZymonicGateway configuration from {GatewayConfigPath}", resolvedGatewayConfigPath); app.UseMiddleware(); app.UseHttpsRedirection(); using (var scope = app.Services.CreateScope()) { var queueStore = scope.ServiceProvider.GetRequiredService(); queueStore.InitialiseDatabase(); var requeuedRequests = await queueStore.RequeueInProgressRequestsAsync(); if (requeuedRequests > 0) { app.Logger.LogWarning("Requeued {RequestCount} in-progress queued requests after gateway startup.", requeuedRequests); } var settings = scope.ServiceProvider.GetRequiredService(); var options = scope.ServiceProvider.GetRequiredService>().Value; ZymonicGatewaySettingsSeeder.Apply(options, settings, app.Logger); } var configuredGatewayOptions = app.Services.GetRequiredService>().Value; var pluginCatalog = app.Services.GetRequiredService(); pluginCatalog.LoadFromOptions(configuredGatewayOptions, app.Logger); foreach (var startupPlugin in pluginCatalog.Plugins.OfType()) { var routeConfigurations = startupPlugin.Routes.ToDictionary( route => route.Name, route => (IReadOnlyDictionary)BuildRouteConfiguration( builder.Configuration.GetSection($"ZymonicGateway:Plugins:{startupPlugin.Name}:{route.Name}"))); await startupPlugin.StartupAsync(new ZymonicGatewayPluginStartupContext { Services = app.Services, RouteConfigurations = routeConfigurations }, CancellationToken.None); } app.MapGet("/status", async (IZymonicGatewayQueueStore queueStore, CancellationToken ct) => { var snapshot = await queueStore.GetStatusSnapshotAsync(ct); return Results.Ok(new { service = "ZymonicGateway", status = "ok", queuedRequests = snapshot.QueuedRequestCount, queuedAgeBuckets = snapshot.QueuedAgeBuckets }); }); app.MapGet("/queue", async (string? status, int? limit, IZymonicGatewayQueueStore queueStore, CancellationToken ct) => { var requests = await queueStore.GetQueuedRequestsAsync(status, limit ?? 100, ct); return Results.Ok(requests); }); app.MapGet("/queue/failed", async (int? limit, IZymonicGatewayQueueStore queueStore, CancellationToken ct) => { var requests = await queueStore.GetQueuedRequestsAsync(ZymonicGatewayQueueStatus.Failed, limit ?? 100, ct); return Results.Ok(requests); }); app.MapGet("/queue/{requestId:guid}", async (Guid requestId, IZymonicGatewayQueueStore queueStore, CancellationToken ct) => { var request = await queueStore.GetQueuedRequestAsync(requestId, ct); return request is null ? Results.NotFound(new { error = $"Queued request '{requestId}' was not found." }) : Results.Ok(request); }); app.MapPut("/queue/{requestId:guid}", async (Guid requestId, ZymonicGatewayQueuedRequest update, IZymonicGatewayQueueStore queueStore, CancellationToken ct) => { var updated = await queueStore.UpdateQueuedRequestAsync(requestId, update, ct); if (!updated) { return Results.NotFound(new { error = $"Queued request '{requestId}' was not found." }); } var request = await queueStore.GetQueuedRequestAsync(requestId, ct); return Results.Ok(request); }); app.MapPost("/queue/{requestId:guid}/retry", async (Guid requestId, bool? debug, bool? disableDebug, IZymonicGatewayQueueStore queueStore, IOptions options, CancellationToken ct) => { var debugMode = disableDebug == true ? false : debug ?? true; var retried = await queueStore.RetryRequestAsync(requestId, options.Value.QueueRunner.MaxAttempts, debugMode, ct); if (!retried) { return Results.NotFound(new { error = $"Queued request '{requestId}' was not found." }); } var request = await queueStore.GetQueuedRequestAsync(requestId, ct); return Results.Ok(request); }); app.MapGet("/debug-rules", (IZymonicGatewayDebugRequestDecider debugDecider) => Results.Ok(new { rules = debugDecider.GetRules() })); app.MapPut("/debug-rules/{name}", (string name, ZymonicGatewayDebugRule rule, IZymonicGatewayDebugRequestDecider debugDecider) => { if (string.IsNullOrWhiteSpace(name)) { return Results.BadRequest(new { error = "Debug rule name is required." }); } return Results.Ok(debugDecider.UpsertRuntimeRule(name, rule)); }); app.MapDelete("/debug-rules/{name}", (string name, IZymonicGatewayDebugRequestDecider debugDecider) => { return debugDecider.RemoveRuntimeRule(name) ? Results.NoContent() : Results.NotFound(new { error = $"Debug rule '{name}' was not found." }); }); app.MapDelete("/debug-rules", (IZymonicGatewayDebugRequestDecider debugDecider) => { debugDecider.ClearRuntimeRules(); return Results.NoContent(); }); app.MapPost("/config/reload", (IConfiguration configuration) => { if (configuration is not IConfigurationRoot configurationRoot) { return Results.StatusCode(500); } configurationRoot.Reload(); return Results.Ok(new { reloadedAtUtc = DateTime.UtcNow }); }); foreach (var plugin in pluginCatalog.Plugins) { foreach (var route in plugin.Routes) { var pluginName = plugin.Name; var routeName = route.Name; var routeConfig = BuildRouteConfiguration( builder.Configuration.GetSection($"ZymonicGateway:Plugins:{pluginName}:{routeName}")); app.MapMethods(route.RoutePattern, route.Methods, async (HttpContext context, CancellationToken ct) => { using var reader = new StreamReader(context.Request.Body, Encoding.UTF8); var body = await reader.ReadToEndAsync(ct); var request = new ZymonicGatewayPluginRequest { Services = context.RequestServices, Method = context.Request.Method, Path = context.Request.Path, QueryString = context.Request.QueryString.HasValue ? context.Request.QueryString.Value : null, Body = body, Headers = context.Request.Headers.ToDictionary( header => header.Key, header => header.Value.Select(value => value ?? "").ToArray()), Configuration = routeConfig }; var response = await route.HandleAsync(request, ct); return Results.Json(response.Body, statusCode: response.StatusCode, contentType: response.ContentType); }); app.Logger.LogInformation( "Mapped ZymonicGateway plugin route {PluginName}/{RouteName} to {Methods} {RoutePattern} with configuration keys: {ConfigurationKeys}", pluginName, routeName, string.Join(",", route.Methods), route.RoutePattern, routeConfig.Count > 0 ? string.Join(",", routeConfig.Keys.OrderBy(key => key)) : "(none)"); } } app.MapGet("/", () => Results.Redirect("/status")); app.Run(); static Dictionary BuildRouteConfiguration(IConfigurationSection routeSection) { return routeSection .GetChildren() .ToDictionary( section => section.Key, section => (string?)(section.Value ?? SerializeConfigurationSection(section))); } static string SerializeConfigurationSection(IConfigurationSection section) { var children = section.GetChildren().ToArray(); if (children.Length > 0 && children.All(child => int.TryParse(child.Key, out _))) { return JsonSerializer.Serialize( children .OrderBy(child => int.Parse(child.Key)) .Select(child => child.Value ?? SerializeConfigurationSection(child)) .ToArray()); } return JsonSerializer.Serialize( children.ToDictionary( child => child.Key, child => child.Value ?? SerializeConfigurationSection(child))); }