< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Documentation.LineEndingDocumentFilter
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Documentation/SwaggerConfiguration.cs
Line coverage
95%
Covered lines: 42
Uncovered lines: 2
Coverable lines: 44
Total lines: 356
Line coverage: 95.4%
Branch coverage
75%
Covered branches: 56
Total branches: 74
Branch coverage: 75.6%
Method coverage
100%
Covered methods: 4
Fully covered methods: 3
Total methods: 4
Method coverage: 100%
Full method coverage: 75%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Apply(...)72.22%3636100%
Apply(...)81.25%1616100%
Apply(...)75%222083.33%
Normalize(...)100%22100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Documentation/SwaggerConfiguration.cs

#LineLine coverage
 1using System.Reflection;
 2using ArturRios.Heimdall.Domain.Enums;
 3using ArturRios.Util.WebApi.Security.Attributes;
 4using Microsoft.AspNetCore.Mvc.Controllers;
 5using Microsoft.OpenApi;
 6using Swashbuckle.AspNetCore.SwaggerGen;
 7
 8namespace ArturRios.Heimdall.WebApi.Documentation;
 9
 10/// <summary>
 11///     The OpenAPI document's title, security, and per-operation documentation.
 12///
 13///     Applied in two places, which is the point of it living here rather than in either of them.
 14///     <c>Startup</c> hands it to ArturRios.Util.WebApi as the Swagger generator's configuration, so
 15///     the running API's Swagger UI shows the controllers' own summaries and marks which endpoints
 16///     need a token. tools/ArturRios.Heimdall.OpenApiGen applies the same method to produce
 17///     docs/openapi/heimdall.json, the document the documentation site publishes. One definition, so
 18///     the published page and the running API cannot describe the same endpoint differently.
 19/// </summary>
 20public static class SwaggerConfiguration
 21{
 22    /// <summary>The document name Swagger UI looks for by default, and the one served at /swagger/v1/…</summary>
 23    public const string DocumentName = "v1";
 24
 25    private const string BearerScheme = "Bearer";
 26
 27    public static void Configure(SwaggerGenOptions options)
 28    {
 29        options.SwaggerDoc(DocumentName, new OpenApiInfo
 30        {
 31            Title = "Heimdall API",
 32            Version = "v1",
 33            Description =
 34                "A centralized identity management API with scope-based multi-tenancy.\n\n"
 35                + "Every identifier in a path is a **PublicId** (GUID), never an internal id. Every "
 36                + "endpoint answers one of two envelopes — `DataOutput<T>` for a single resource, "
 37                + "`PaginatedOutput<T>` for a listing.\n\n"
 38                + "Obtain a token from `POST /api/auth/login`, then authorize with it. A challenge "
 39                + "token issued by a 2FA-gated login is not one: no endpoint accepts it as a bearer "
 40                + "credential, and it is read only from the `challengeToken` body field of "
 41                + "`POST /api/auth/2fa/verify` and `POST /api/auth/2fa/challenge/resend`.",
 42            Contact = new OpenApiContact
 43            {
 44                Name = "Artur Rios",
 45                Url = new Uri("https://github.com/artur-rios/heimdall-api")
 46            },
 47            // The repository's own LICENSE, by the name its first line gives it: the Artur Rios
 48            // Attribution License has no SPDX identifier, so the name and URL stand for it.
 49            License = new OpenApiLicense
 50            {
 51                Name = "Artur Rios Attribution License",
 52                Url = new Uri("https://github.com/artur-rios/heimdall-api/blob/main/LICENSE")
 53            }
 54        });
 55
 56        // The only definition of "Bearer" in either place: Startup leaves Util.WebApi's JWT scheme
 57        // off, because AddSecurityDefinition throws on a duplicate key and the library's runs after
 58        // this method. Assigned rather than added all the same, so a second definition overwrites
 59        // this one instead of failing start-up.
 60        options.SwaggerGeneratorOptions.SecuritySchemes[BearerScheme] = new OpenApiSecurityScheme
 61        {
 62            Type = SecuritySchemeType.Http,
 63            Scheme = "bearer",
 64            BearerFormat = "JWT",
 65            Description = "Paste the token returned by POST /api/auth/login. The 'Bearer ' prefix is added for you."
 66        };
 67
 68        // The XML documentation on the controllers is the whole reason the page is worth reading,
 69        // so a missing file is a failure rather than something to shrug off.
 70        var xmlPath = Path.Combine(
 71            AppContext.BaseDirectory,
 72            $"{typeof(SwaggerConfiguration).Assembly.GetName().Name}.xml");
 73
 74        if (!File.Exists(xmlPath))
 75        {
 76            throw new FileNotFoundException(
 77                $"The Web API's XML documentation file was not found at {xmlPath}. It is produced by "
 78                + "<GenerateDocumentationFile> in ArturRios.Heimdall.WebApi.csproj — check that it is "
 79                + "still set, and that the project was rebuilt after it.",
 80                xmlPath);
 81        }
 82
 83        options.IncludeXmlComments(xmlPath);
 84
 85        options.DocumentFilter<SecurityDocumentFilter>();
 86
 87        // Runs last, and must: it normalises text the filters above also write.
 88        options.DocumentFilter<LineEndingDocumentFilter>();
 89        options.CustomOperationIds(description =>
 90            (description.ActionDescriptor as ControllerActionDescriptor)
 91            is { } action
 92                ? $"{action.ControllerName}_{action.ActionName}"
 93                : null);
 94    }
 95}
 96
 97/// <summary>
 98///     Marks each operation with the security it actually requires, and documents the rejections
 99///     that security produces.
 100///
 101///     Authentication here is Heimdall's own — <c>AllowAnonymousAttribute</c> and
 102///     <c>RoleRequirementAttribute</c> from ArturRios.Util.WebApi, not ASP.NET Core's — so
 103///     Swashbuckle's built-in handling of <c>[Authorize]</c> sees nothing and every operation would
 104///     otherwise be documented as open. The default is the reverse of ASP.NET Core's: an operation is
 105///     authenticated unless it opts out, which is how the API behaves.
 106///
 107///     This is a document filter rather than an operation filter because a security requirement is a
 108///     reference to a scheme, and a reference only serializes once it is bound to the document that
 109///     defines the scheme. An operation filter never sees that document, and the requirement
 110///     serializes as an empty object.
 111/// </summary>
 112public sealed class SecurityDocumentFilter : IDocumentFilter
 113{
 114    private const string BearerScheme = "Bearer";
 115
 116    public void Apply(OpenApiDocument document, DocumentFilterContext context)
 117    {
 118        // CustomOperationIds gives every action a Controller_Action id, which is what ties an
 119        // operation in the finished document back to the method it came from.
 120        var methods = context.ApiDescriptions
 121            .Where(description => description.ActionDescriptor is ControllerActionDescriptor)
 122            .ToDictionary(
 123                description =>
 124                {
 125                    var action = (ControllerActionDescriptor)description.ActionDescriptor;
 126
 127                    return $"{action.ControllerName}_{action.ActionName}";
 128                },
 129                description => ((ControllerActionDescriptor)description.ActionDescriptor).MethodInfo);
 130
 131        // The document-wide default: authenticated unless an operation says otherwise. Assigned
 132        // rather than appended, so it stays the single requirement even if something else adds one.
 133        document.Security =
 134        [
 135            new OpenApiSecurityRequirement
 136            {
 137                [new OpenApiSecuritySchemeReference(BearerScheme, document)] = []
 138            }
 139        ];
 140
 141        foreach (var operation in document.Paths.Values.SelectMany(path =>
 142                     path.Operations?.Values ?? Enumerable.Empty<OpenApiOperation>()))
 143        {
 144            if (operation.OperationId is null || !methods.TryGetValue(operation.OperationId, out var method))
 145            {
 146                continue;
 147            }
 148
 149            Apply(document, operation, method);
 150        }
 151    }
 152
 153    private static void Apply(OpenApiDocument document, OpenApiOperation operation, MethodInfo method)
 154    {
 155        var attributes = method
 156            .GetCustomAttributes(inherit: true)
 157            .Concat(method.DeclaringType?.GetCustomAttributes(inherit: true) ?? [])
 158            .ToList();
 159
 160        if (attributes.OfType<AllowAnonymousAttribute>().Any())
 161        {
 162            // An empty requirement list, not an absent one. The document carries a default that
 163            // applies to every operation which does not state its own, so saying nothing here would
 164            // document an anonymous endpoint as needing a token; an empty list is how OpenAPI spells
 165            // "this one overrides the default with nothing".
 166            operation.Security = [];
 167
 168            operation.Description = Append(operation.Description, "**Anonymous** — no bearer token required.");
 169
 170            return;
 171        }
 172
 173        operation.Security =
 174        [
 175            new OpenApiSecurityRequirement
 176            {
 177                [new OpenApiSecuritySchemeReference(BearerScheme, document)] = []
 178            }
 179        ];
 180
 181        operation.Responses ??= new OpenApiResponses();
 182
 183        operation.Responses.TryAdd(
 184            "401",
 185            new OpenApiResponse { Description = "The token is missing, malformed, expired, or is a 2FA challenge token."
 186
 187        var roles = attributes
 188            .OfType<RoleRequirementAttribute>()
 189            .SelectMany(RoleNames)
 190            .Distinct()
 191            .ToList();
 192
 193        if (roles.Count == 0)
 194        {
 195            operation.Description = Append(
 196                operation.Description,
 197                "**Any authenticated caller** — the handler decides who may act, so no role is required at the door.");
 198
 199            return;
 200        }
 201
 202        operation.Responses.TryAdd(
 203            "403",
 204            new OpenApiResponse { Description = "The token is valid but the caller does not hold a required role." });
 205
 206        operation.Description = Append(operation.Description, $"**Requires role:** {string.Join(" or ", roles)}.");
 207    }
 208
 209    /// <summary>
 210    ///     Reads the role ids off a <c>RoleRequirementAttribute</c> and names them.
 211    ///
 212    ///     The attribute is a <c>TypeFilterAttribute</c>: it carries no role property of its own, and
 213    ///     passes the ids to its filter through <c>Arguments</c>. An attribute that stops carrying
 214    ///     them yields nothing, and the operation is documented without a role line — the security
 215    ///     requirement above, which is what actually matters, does not depend on this.
 216    /// </summary>
 217    private static IEnumerable<string> RoleNames(RoleRequirementAttribute attribute) =>
 218        (attribute.Arguments ?? [])
 219        .OfType<IEnumerable<int>>()
 220        .SelectMany(ids => ids)
 221        .Select(id => Enum.IsDefined(typeof(Roles), id)
 222            ? SplitPascalCase(((Roles)id).ToString())
 223            : $"role {id}");
 224
 225    private static string SplitPascalCase(string value) =>
 226        string.Concat(value.Select((character, index) =>
 227            index > 0 && char.IsUpper(character) ? $" {character}" : $"{character}"));
 228
 229    private static string Append(string? description, string line) =>
 230        string.IsNullOrWhiteSpace(description) ? line : $"{description}\n\n{line}";
 231}
 232
 233/// <summary>
 234///     Rewrites every CRLF in the finished document to LF.
 235///
 236///     Swashbuckle builds an operation's summary from the XML documentation file and rejoins the
 237///     lines with <see cref="Environment.NewLine" />, so a single source comment becomes a summary
 238///     broken by CRLF on Windows and by LF on Linux. Normalising the XML file on the way in does not
 239///     help: the newlines are put back after it is read.
 240///
 241///     Nobody reading the page can tell the difference, but scripts/openapi.py --check compares
 242///     bytes — so without this, the document generated on a developer's Windows machine and the one
 243///     CI regenerates on Linux describe the very same API and still differ, in 46 hunks of pure line
 244///     ending. LF is the side to standardise on: it is what .gitattributes pins the committed file
 245///     to.
 246/// </summary>
 247public sealed class LineEndingDocumentFilter : IDocumentFilter
 248{
 249    public void Apply(OpenApiDocument document, DocumentFilterContext context)
 250    {
 2251        if (document.Info is { } info)
 252        {
 2253            info.Title = Normalize(info.Title);
 2254            info.Description = Normalize(info.Description);
 2255            info.Summary = Normalize(info.Summary);
 256        }
 257
 44258        foreach (var tag in document.Tags ?? Enumerable.Empty<OpenApiTag>())
 259        {
 20260            if (tag is OpenApiTag concrete)
 261            {
 20262                concrete.Description = Normalize(concrete.Description);
 263            }
 264        }
 265
 8266        foreach (var scheme in document.Components?.SecuritySchemes?.Values ?? [])
 267        {
 2268            if (scheme is OpenApiSecurityScheme concrete)
 269            {
 2270                concrete.Description = Normalize(concrete.Description);
 271            }
 272        }
 273
 228274        foreach (var pathItem in document.Paths.Values)
 275        {
 112276            if (pathItem is OpenApiPathItem concrete)
 277            {
 112278                concrete.Summary = Normalize(concrete.Summary);
 112279                concrete.Description = Normalize(concrete.Description);
 280            }
 281
 512282            foreach (var operation in pathItem.Operations?.Values ?? Enumerable.Empty<OpenApiOperation>())
 283            {
 144284                Apply(operation);
 285            }
 286        }
 287
 288        // Schemas are walked with a seen-set: a $ref'd schema is shared between the operations that
 289        // use it, and a self-referencing one would otherwise recurse forever.
 2290        var seen = new HashSet<IOpenApiSchema>();
 291
 696292        foreach (var schema in document.Components?.Schemas?.Values ?? [])
 293        {
 346294            Apply(schema, seen);
 295        }
 2296    }
 297
 298    private static void Apply(OpenApiOperation operation)
 299    {
 144300        operation.Summary = Normalize(operation.Summary);
 144301        operation.Description = Normalize(operation.Description);
 302
 748303        foreach (var parameter in operation.Parameters ?? [])
 304        {
 230305            if (parameter is OpenApiParameter concrete)
 306            {
 230307                concrete.Description = Normalize(concrete.Description);
 308            }
 309        }
 310
 144311        if (operation.RequestBody is OpenApiRequestBody requestBody)
 312        {
 62313            requestBody.Description = Normalize(requestBody.Description);
 314        }
 315
 1004316        foreach (var response in operation.Responses?.Values ?? Enumerable.Empty<IOpenApiResponse>())
 317        {
 358318            if (response is OpenApiResponse concrete)
 319            {
 358320                concrete.Description = Normalize(concrete.Description);
 321            }
 322        }
 144323    }
 324
 325    private static void Apply(IOpenApiSchema schema, HashSet<IOpenApiSchema> seen)
 326    {
 2262327        if (!seen.Add(schema))
 328        {
 0329            return;
 330        }
 331
 2262332        if (schema is OpenApiSchema concrete)
 333        {
 2104334            concrete.Title = Normalize(concrete.Title);
 2104335            concrete.Description = Normalize(concrete.Description);
 336        }
 337
 7672338        foreach (var property in schema.Properties?.Values ?? [])
 339        {
 1574340            Apply(property, seen);
 341        }
 342
 2262343        if (schema.Items is { } items)
 344        {
 342345            Apply(items, seen);
 346        }
 347
 4524348        foreach (var composed in (schema.AllOf ?? []).Concat(schema.AnyOf ?? []).Concat(schema.OneOf ?? []))
 349        {
 0350            Apply(composed, seen);
 351        }
 2262352    }
 353
 354    private static string? Normalize(string? text) =>
 5398355        text?.ReplaceLineEndings("\n");
 356}