< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Hosting.KestrunHostHealthExtensions
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Hosting/KestrunHostHealthExtensions.cs
Tag: Kestrun/Kestrun@0d738bf294e6281b936d031e1979d928007495ff
Line coverage
0%
Covered lines: 0
Uncovered lines: 167
Coverable lines: 167
Total lines: 267
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 52
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 10/13/2025 - 16:52:37 Line coverage: 0% (0/168) Branch coverage: 0% (0/52) Total lines: 262 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e12/12/2025 - 17:27:19 Line coverage: 0% (0/167) Branch coverage: 0% (0/52) Total lines: 267 Tag: Kestrun/Kestrun@826bf9dcf9db118c5de4c78a3259bce9549f0dcd 10/13/2025 - 16:52:37 Line coverage: 0% (0/168) Branch coverage: 0% (0/52) Total lines: 262 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e12/12/2025 - 17:27:19 Line coverage: 0% (0/167) Branch coverage: 0% (0/52) Total lines: 267 Tag: Kestrun/Kestrun@826bf9dcf9db118c5de4c78a3259bce9549f0dcd

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
AddHealthEndpoint(...)0%156120%
AddHealthEndpoint(...)100%210%
ExtractTags(...)0%210140%
CopyHealthEndpointOptions(...)0%272160%
DetermineStatusCode(...)0%4260%
MapHealthEndpointImmediate(...)0%2040%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Hosting/KestrunHostHealthExtensions.cs

#LineLine coverage
 1using Kestrun.Health;
 2using Kestrun.Hosting.Options;
 3using Kestrun.Models;
 4using Kestrun.Scripting;
 5using Kestrun.Utilities;
 6using Microsoft.Net.Http.Headers;
 7using System.Text.Json;
 8using System.Text.Json.Serialization;
 9
 10namespace Kestrun.Hosting;
 11
 12/// <summary>
 13/// Adds health-check specific helpers to <see cref="KestrunHost"/>.
 14/// </summary>
 15public static class KestrunHostHealthExtensions
 16{
 17    private static readonly JsonSerializerOptions JsonOptions;
 18
 19    static KestrunHostHealthExtensions()
 20    {
 021        JsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
 022        {
 023            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
 024            WriteIndented = false
 025        };
 026        JsonOptions.Converters.Add(new JsonStringEnumConverter());
 027    }
 28
 29    /// <summary>
 30    /// Registers a GET endpoint (default <c>/health</c>) that aggregates the state of all registered probes.
 31    /// </summary>
 32    /// <param name="host">The host to configure.</param>
 33    /// <param name="configure">Optional action to mutate the default endpoint options.</param>
 34    /// <returns>The <see cref="KestrunHost"/> instance for fluent chaining.</returns>
 35    public static KestrunHost AddHealthEndpoint(this KestrunHost host, Action<HealthEndpointOptions>? configure = null)
 36    {
 037        ArgumentNullException.ThrowIfNull(host);
 38
 039        var merged = host.Options.Health?.Clone() ?? new HealthEndpointOptions();
 040        configure?.Invoke(merged);
 41
 042        var mapOptions = new MapRouteOptions
 043        {
 044            Pattern = merged.Pattern,
 045            HttpVerbs = [HttpVerb.Get],
 046            ScriptCode = new LanguageOptions
 047            {
 048                Language = ScriptLanguage.Native,
 049            },
 050            AllowAnonymous = merged.AllowAnonymous,
 051            DisableAntiforgery = true,
 052            RequireSchemes = [.. merged.RequireSchemes],
 053            RequirePolicies = [.. merged.RequirePolicies],
 054            CorsPolicy = merged.CorsPolicy ?? string.Empty,
 055            RateLimitPolicyName = merged.RateLimitPolicyName,
 056            ShortCircuit = merged.ShortCircuit,
 057            ShortCircuitStatusCode = merged.ShortCircuitStatusCode,
 058            ThrowOnDuplicate = merged.ThrowOnDuplicate,
 059        };
 060        mapOptions.OpenAPI.Add(HttpVerb.Get, new OpenAPIMetadata(pattern: merged.Pattern)
 061        {
 062            Summary = merged.OpenApiSummary,
 063            Description = merged.OpenApiDescription,
 064            OperationId = merged.OpenApiOperationId,
 065            Tags = merged.OpenApiTags
 066        });
 67
 68        // Auto-register endpoint only when enabled
 069        if (!merged.AutoRegisterEndpoint)
 70        {
 071            host.Logger.Debug("Health endpoint AutoRegisterEndpoint=false; skipping automatic mapping for pattern {Patte
 072            return host;
 73        }
 74
 75        // If the app pipeline is already built/configured, map immediately; otherwise defer until build
 076        if (host.IsConfigured)
 77        {
 078            MapHealthEndpointImmediate(host, merged, mapOptions);
 079            return host;
 80        }
 81
 082        return host.Use(app => MapHealthEndpointImmediate(host, merged, mapOptions));
 83    }
 84
 85    /// <summary>
 86    /// Registers a GET endpoint (default <c>/health</c>) using a pre-configured <see cref="HealthEndpointOptions"/> ins
 87    /// </summary>
 88    /// <param name="host">The host to configure.</param>
 89    /// <param name="options">A fully configured options object.</param>
 90    /// <returns>The <see cref="KestrunHost"/> instance for fluent chaining.</returns>
 91    public static KestrunHost AddHealthEndpoint(this KestrunHost host, HealthEndpointOptions options)
 92    {
 093        ArgumentNullException.ThrowIfNull(host);
 094        ArgumentNullException.ThrowIfNull(options);
 95
 096        return host.AddHealthEndpoint(dest => CopyHealthEndpointOptions(options, dest));
 97    }
 98
 99    // ApplyConventions removed; unified with KestrunHostMapExtensions.AddMapOptions
 100
 101    private static string[] ExtractTags(HttpRequest request)
 102    {
 0103        var collected = new List<string>();
 0104        if (request.Query.TryGetValue("tag", out var singleValues))
 105        {
 0106            foreach (var value in singleValues)
 107            {
 0108                if (!string.IsNullOrEmpty(value))
 109                {
 0110                    collected.AddRange(value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyE
 111                }
 112            }
 113        }
 114
 0115        if (request.Query.TryGetValue("tags", out var multiValues))
 116        {
 0117            foreach (var value in multiValues)
 118            {
 0119                if (!string.IsNullOrEmpty(value))
 120                {
 0121                    collected.AddRange(value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyE
 122                }
 123            }
 124        }
 125
 0126        return collected.Count == 0
 0127            ? []
 0128            : [.. collected.Where(static t => !string.IsNullOrWhiteSpace(t))
 0129                           .Select(static t => t.Trim())
 0130                           .Distinct(StringComparer.OrdinalIgnoreCase)];
 131    }
 132
 133    private static void CopyHealthEndpointOptions(HealthEndpointOptions source, HealthEndpointOptions target)
 134    {
 0135        ArgumentNullException.ThrowIfNull(source);
 0136        ArgumentNullException.ThrowIfNull(target);
 137
 0138        target.Pattern = source.Pattern;
 0139        target.DefaultTags = source.DefaultTags is { Length: > 0 } tags
 0140            ? [.. tags]
 0141            : Array.Empty<string>();
 0142        target.AllowAnonymous = source.AllowAnonymous;
 0143        target.TreatDegradedAsUnhealthy = source.TreatDegradedAsUnhealthy;
 0144        target.ThrowOnDuplicate = source.ThrowOnDuplicate;
 0145        target.RequireSchemes = source.RequireSchemes is { Length: > 0 } schemes
 0146            ? [.. schemes]
 0147            : Array.Empty<string>();
 0148        target.RequirePolicies = source.RequirePolicies is { Length: > 0 } policies
 0149            ? [.. policies]
 0150            : Array.Empty<string>();
 0151        target.CorsPolicy = source.CorsPolicy;
 0152        target.RateLimitPolicyName = source.RateLimitPolicyName;
 0153        target.ShortCircuit = source.ShortCircuit;
 0154        target.ShortCircuitStatusCode = source.ShortCircuitStatusCode;
 0155        target.OpenApiSummary = source.OpenApiSummary;
 0156        target.OpenApiDescription = source.OpenApiDescription;
 0157        target.OpenApiOperationId = source.OpenApiOperationId;
 0158        target.OpenApiTags = source.OpenApiTags is { Count: > 0 } openApiTags
 0159            ? [.. openApiTags]
 0160            : new List<string>();
 0161        target.OpenApiGroupName = source.OpenApiGroupName;
 0162        target.MaxDegreeOfParallelism = source.MaxDegreeOfParallelism;
 0163        target.ProbeTimeout = source.ProbeTimeout;
 0164        target.AutoRegisterEndpoint = source.AutoRegisterEndpoint;
 0165        target.DefaultScriptLanguage = source.DefaultScriptLanguage;
 166        // BUGFIX: Ensure the response content type preference is propagated when using the overload
 167        // that accepts a pre-configured HealthEndpointOptions instance. Without this line the
 168        // ResponseContentType would always fall back to Json for PowerShell Add-KrHealthEndpoint
 169        // which calls AddHealthEndpoint(host, options) internally.
 0170        target.ResponseContentType = source.ResponseContentType;
 0171        target.XmlRootElementName = source.XmlRootElementName;
 0172        target.Compress = source.Compress;
 0173    }
 0174    private static int DetermineStatusCode(ProbeStatus status, bool treatDegradedAsUnhealthy) => status switch
 0175    {
 0176        ProbeStatus.Healthy => StatusCodes.Status200OK,
 0177        ProbeStatus.Degraded when !treatDegradedAsUnhealthy => StatusCodes.Status200OK,
 0178        _ => StatusCodes.Status503ServiceUnavailable
 0179    };
 180
 181    /// <summary>
 182    /// Maps the health endpoint immediately.
 183    /// </summary>
 184    /// <param name="host">The KestrunHost instance.</param>
 185    /// <param name="merged">The merged HealthEndpointOptions instance.</param>
 186    /// <param name="mapOptions">The route mapping options.</param>
 187    /// <exception cref="InvalidOperationException">Thrown if a route with the same pattern and HTTP verb already exists
 188    private static void MapHealthEndpointImmediate(KestrunHost host, HealthEndpointOptions merged, MapRouteOptions mapOp
 189    {
 0190        if (host.MapExists(mapOptions.Pattern!, HttpVerb.Get))
 191        {
 0192            var message = $"Route '{mapOptions.Pattern}' (GET) already exists. Skipping health endpoint registration.";
 0193            if (merged.ThrowOnDuplicate)
 194            {
 0195                throw new InvalidOperationException(message);
 196            }
 0197            host.Logger.Warning(message);
 0198            return;
 199        }
 200
 201        // Acquire WebApplication (throws if Build() truly has not executed yet). Using host.App here allows
 202        // early AddHealthEndpoint calls before EnableConfiguration via deferred middleware.
 0203        var endpoints = host.App;
 0204        var endpointLogger = host.Logger.ForContext("HealthEndpoint", merged.Pattern);
 205
 0206        var map = endpoints.MapMethods(merged.Pattern, [HttpMethods.Get], async context =>
 0207        {
 0208            var requestTags = ExtractTags(context.Request);
 0209            var tags = requestTags.Length > 0 ? requestTags : merged.DefaultTags;
 0210            var snapshot = host.GetHealthProbesSnapshot();
 0211
 0212            var report = await HealthProbeRunner.RunAsync(
 0213                probes: snapshot,
 0214                tagFilter: tags,
 0215                perProbeTimeout: merged.ProbeTimeout,
 0216                maxDegreeOfParallelism: merged.MaxDegreeOfParallelism,
 0217                logger: endpointLogger,
 0218                ct: context.RequestAborted).ConfigureAwait(false);
 0219
 0220            var request = await KestrunRequest.NewRequest(context).ConfigureAwait(false);
 0221            var response = new KestrunResponse(request)
 0222            {
 0223                CacheControl = new CacheControlHeaderValue
 0224                {
 0225                    NoCache = true,
 0226                    NoStore = true,
 0227                    MustRevalidate = true,
 0228                    MaxAge = TimeSpan.Zero
 0229                }
 0230            };
 0231            context.Response.Headers.Pragma = "no-cache";
 0232            context.Response.Headers.Expires = "0";
 0233
 0234            var statusCode = DetermineStatusCode(report.Status, merged.TreatDegradedAsUnhealthy);
 0235            switch (merged.ResponseContentType)
 0236            {
 0237                case HealthEndpointContentType.Json:
 0238                    await response.WriteJsonResponseAsync(report, depth: 10, compress: merged.Compress, statusCode: stat
 0239                    break;
 0240                case HealthEndpointContentType.Yaml:
 0241                    await response.WriteYamlResponseAsync(report, statusCode).ConfigureAwait(false);
 0242                    break;
 0243                case HealthEndpointContentType.Xml:
 0244                    await response.WriteXmlResponseAsync(
 0245                        report,
 0246                        statusCode,
 0247                        rootElementName: merged.XmlRootElementName ?? "Response",
 0248                        compress: merged.Compress).ConfigureAwait(false);
 0249                    break;
 0250                case HealthEndpointContentType.Text:
 0251                    var text = HealthReportTextFormatter.Format(report);
 0252                    await response.WriteTextResponseAsync(text, statusCode, contentType: $"text/plain; charset={response
 0253                    break;
 0254                case HealthEndpointContentType.Auto:
 0255                default:
 0256                    await response.WriteResponseAsync(report, statusCode).ConfigureAwait(false);
 0257                    break;
 0258            }
 0259
 0260            await response.ApplyTo(context.Response).ConfigureAwait(false);
 0261        }).WithMetadata(new ScriptLanguageAttribute(ScriptLanguage.Native));
 262
 0263        host.AddMapOptions(map, mapOptions);
 0264        host._registeredRoutes[(mapOptions.Pattern!, HttpVerb.Get)] = mapOptions;
 0265        host.Logger.Information("Registered health endpoint at {Pattern}", mapOptions.Pattern);
 0266    }
 267}