using FastEndpoints.Swagger; using GB5Shared.Hosting; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; namespace GB5Shared.Swagger; /// /// Registers one native FastEndpoints/NSwag SwaggerDocument PER MODULE bundled into a consolidated /// Host (BusinessHost, EngagementHost, HRFinanceHost, PlatformHost), instead of a single combined /// document covering every module. Each document's DocumentName is the module's display name (e.g. /// "Admin"), so its swagger.json is served at /swagger/{ModuleName}/swagger.json and its own SwaggerUi /// page - hitting a module's gateway alias (e.g. /ads/GB5Documentation) then only ever needs to know /// its own DocumentName, no request-time filtering required. Filtering happens once at document /// generation via (assigns each operation's owning module as a tag) /// followed by (drops any operation whose tag isn't this /// document's module) - covers FastEndpoints AND MVC/Dapr-controller-sourced operations alike, since /// both flow through the same NSwag operation pipeline. /// public static class ModuleSwaggerRegistration { /// /// Header the gateway sets to the resolved module display name (e.g. "Admin") for the route the /// request matched - see GB5Build's Program.cs reverse-proxy transform. Direct port access never /// sends this, so /GB5Documentation falls back to the first module alphabetically in that case. /// public const string ModuleHeaderName = "X-GB5-Gateway-Module"; public static void AddPerModuleSwaggerDocuments( this IServiceCollection services, ModuleLoadResult moduleLoad, string hostTitle, Dictionary? pathAliases = null) { var moduleByAssembly = moduleLoad.BuildModuleByAssembly(); foreach (var moduleName in moduleByAssembly.Values.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(n => n, StringComparer.Ordinal)) { services.SwaggerDocument(o => { o.DocumentSettings = s => { s.DocumentName = moduleName; s.Title = $"{hostTitle} - {moduleName}"; s.Version = "v1"; s.Description = $"{hostTitle} - {moduleName} module"; s.OperationProcessors.Add(new ModuleTagProcessor(moduleByAssembly, pathAliases)); s.OperationProcessors.Add(new ModuleScopedProcessor(moduleName)); }; o.EnableJWTBearerAuth = false; o.ShortSchemaNames = true; o.AutoTagPathSegmentIndex = 0; }); } } /// /// Maps a SwaggerUi page per module at /GB5Documentation/{ModuleName}, plus every module's own /// gateway alias landing at /GB5Documentation resolving to that module's document when reached /// through the gateway (X-GB5-Gateway-Module header set per YARP route in GB5Build's /// appsettings.json) - falling back to the first module alphabetically for direct port access. /// public static void MapPerModuleSwaggerUi(this WebApplication app, ModuleLoadResult moduleLoad) { var moduleNames = moduleLoad.BuildModuleByAssembly().Values .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(n => n, StringComparer.Ordinal) .ToList(); // DocumentPath/TransformToExternalPath only rewrite the SwaggerUi PAGE's own links - the // swagger.json body's "servers" entry (which Swagger UI's JS actually fetches operations // against) is untouched by those and defaults to no prefix. Reached directly this is fine, // but through the gateway (PathBase set by GatewayPrefixMiddleware from // X-GB5-Gateway-Prefix) the browser needs an explicit "servers" entry pointing back at that // prefix, or "Try it out" and any prefix-relative fetch 404s. PostProcess runs per-request, // after generation, specifically for this reverse-proxy scenario (see NSwag's own docs on // OpenApiDocumentMiddlewareSettings.PostProcess). app.UseOpenApi(o => { o.Path = "/swagger/{documentName}/swagger.json"; o.PostProcess = (document, request) => { if (request.PathBase.HasValue) { document.Servers.Clear(); document.Servers.Add(new NSwag.OpenApiServer { Url = request.PathBase.Value }); } }; }); foreach (var moduleName in moduleNames) { app.UseSwaggerUi(o => { o.DocumentPath = $"/swagger/{moduleName}/swagger.json"; o.Path = $"/GB5Documentation/{moduleName}"; o.TransformToExternalPath = (internalUiRoute, request) => request.PathBase.HasValue ? request.PathBase.Value + internalUiRoute : internalUiRoute; }); } // Handle both the bare landing path and a direct "/index.html" hit (e.g. an old bookmarked // link like /tms/GB5Documentation/index.html) - both resolve to the requesting gateway // alias's own module page. Swagger UI's own index.html reads the swagger.json URL from a // "?url=" query string set by NSwag's own redirect (GB5Documentation/{module} -> .../index.html // ?url=/swagger/{module}/swagger.json) - hitting index.html directly with no query string // leaves Swagger UI's "url" JS variable undefined, producing "Fetch error, Not Found undefined". // This redirect must include that same "?url=" parameter, not just the bare index.html path. RequestDelegate redirectToModuleDoc = context => { var moduleHeader = context.Request.Headers[ModuleHeaderName].ToString(); var targetModule = moduleNames.FirstOrDefault(m => string.Equals(m, moduleHeader, StringComparison.OrdinalIgnoreCase)) ?? moduleNames.FirstOrDefault() ?? string.Empty; var prefix = context.Request.PathBase.HasValue ? context.Request.PathBase.Value : string.Empty; context.Response.Redirect($"{prefix}/GB5Documentation/{targetModule}/index.html?url={prefix}/swagger/{targetModule}/swagger.json"); return Task.CompletedTask; }; app.MapGet("/GB5Documentation", redirectToModuleDoc); app.MapGet("/GB5Documentation/index.html", redirectToModuleDoc); // A direct hit on a specific module's own "/index.html" (e.g. a bookmarked link, or typing // the URL by hand) carries no "?url=" query string either - NSwag's own redirect only adds // it when navigating to the bare "/GB5Documentation/{module}" path, not ".../index.html" // directly. UseSwaggerUi's own middleware (registered above, runs ahead of endpoint routing) // already intercepts and serves ".../index.html" whenever a "?url=" query string is present, // so this only needs to handle the no-query-string case - it never reaches here otherwise. foreach (var moduleName in moduleNames) { app.MapGet($"/GB5Documentation/{moduleName}/index.html", context => { var prefix = context.Request.PathBase.HasValue ? context.Request.PathBase.Value : string.Empty; context.Response.Redirect($"{prefix}/GB5Documentation/{moduleName}/index.html?url={prefix}/swagger/{moduleName}/swagger.json"); return Task.CompletedTask; }); } } }