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;
});
}
}
}