< 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@2d87023b37eb91155071c91dd3d6a2eeb3004705
Line coverage
0%
Covered lines: 0
Uncovered lines: 168
Coverable lines: 168
Total lines: 262
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@10d476bee71c71ad215bb8ab59f219887b5b4a5e 10/13/2025 - 16:52:37 Line coverage: 0% (0/168) Branch coverage: 0% (0/52) Total lines: 262 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e

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            CorsPolicyName = merged.CorsPolicyName ?? string.Empty,
 055            RateLimitPolicyName = merged.RateLimitPolicyName,
 056            ShortCircuit = merged.ShortCircuit,
 057            ShortCircuitStatusCode = merged.ShortCircuitStatusCode,
 058            ThrowOnDuplicate = merged.ThrowOnDuplicate,
 059            OpenAPI = new OpenAPIMetadata
 060            {
 061                Summary = merged.OpenApiSummary,
 062                Description = merged.OpenApiDescription,
 063                OperationId = merged.OpenApiOperationId,
 064                Tags = merged.OpenApiTags,
 065                GroupName = merged.OpenApiGroupName
 066            }
 067        };
 68
 69
 70        // Auto-register endpoint only when enabled
 071        if (!merged.AutoRegisterEndpoint)
 72        {
 073            host.Logger.Debug("Health endpoint AutoRegisterEndpoint=false; skipping automatic mapping for pattern {Patte
 074            return host;
 75        }
 76
 77        // If the app pipeline is already built/configured, map immediately; otherwise defer until build
 078        if (host.IsConfigured)
 79        {
 080            MapHealthEndpointImmediate(host, merged, mapOptions);
 081            return host;
 82        }
 83
 084        return host.Use(app => MapHealthEndpointImmediate(host, merged, mapOptions));
 85    }
 86
 87    /// <summary>
 88    /// Registers a GET endpoint (default <c>/health</c>) using a pre-configured <see cref="HealthEndpointOptions"/> ins
 89    /// </summary>
 90    /// <param name="host">The host to configure.</param>
 91    /// <param name="options">A fully configured options object.</param>
 92    /// <returns>The <see cref="KestrunHost"/> instance for fluent chaining.</returns>
 93    public static KestrunHost AddHealthEndpoint(this KestrunHost host, HealthEndpointOptions options)
 94    {
 095        ArgumentNullException.ThrowIfNull(host);
 096        ArgumentNullException.ThrowIfNull(options);
 97
 098        return host.AddHealthEndpoint(dest => CopyHealthEndpointOptions(options, dest));
 99    }
 100
 101    // ApplyConventions removed; unified with KestrunHostMapExtensions.AddMapOptions
 102
 103    private static string[] ExtractTags(HttpRequest request)
 104    {
 0105        var collected = new List<string>();
 0106        if (request.Query.TryGetValue("tag", out var singleValues))
 107        {
 0108            foreach (var value in singleValues)
 109            {
 0110                if (!string.IsNullOrEmpty(value))
 111                {
 0112                    collected.AddRange(value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyE
 113                }
 114            }
 115        }
 116
 0117        if (request.Query.TryGetValue("tags", out var multiValues))
 118        {
 0119            foreach (var value in multiValues)
 120            {
 0121                if (!string.IsNullOrEmpty(value))
 122                {
 0123                    collected.AddRange(value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyE
 124                }
 125            }
 126        }
 127
 0128        return collected.Count == 0
 0129            ? []
 0130            : [.. collected.Where(static t => !string.IsNullOrWhiteSpace(t))
 0131                           .Select(static t => t.Trim())
 0132                           .Distinct(StringComparer.OrdinalIgnoreCase)];
 133    }
 134
 135    private static void CopyHealthEndpointOptions(HealthEndpointOptions source, HealthEndpointOptions target)
 136    {
 0137        ArgumentNullException.ThrowIfNull(source);
 0138        ArgumentNullException.ThrowIfNull(target);
 139
 0140        target.Pattern = source.Pattern;
 0141        target.DefaultTags = source.DefaultTags is { Length: > 0 } tags
 0142            ? [.. tags]
 0143            : Array.Empty<string>();
 0144        target.AllowAnonymous = source.AllowAnonymous;
 0145        target.TreatDegradedAsUnhealthy = source.TreatDegradedAsUnhealthy;
 0146        target.ThrowOnDuplicate = source.ThrowOnDuplicate;
 0147        target.RequireSchemes = source.RequireSchemes is { Length: > 0 } schemes
 0148            ? [.. schemes]
 0149            : Array.Empty<string>();
 0150        target.RequirePolicies = source.RequirePolicies is { Length: > 0 } policies
 0151            ? [.. policies]
 0152            : Array.Empty<string>();
 0153        target.CorsPolicyName = source.CorsPolicyName;
 0154        target.RateLimitPolicyName = source.RateLimitPolicyName;
 0155        target.ShortCircuit = source.ShortCircuit;
 0156        target.ShortCircuitStatusCode = source.ShortCircuitStatusCode;
 0157        target.OpenApiSummary = source.OpenApiSummary;
 0158        target.OpenApiDescription = source.OpenApiDescription;
 0159        target.OpenApiOperationId = source.OpenApiOperationId;
 0160        target.OpenApiTags = source.OpenApiTags is { Length: > 0 } openApiTags
 0161            ? [.. openApiTags]
 0162            : Array.Empty<string>();
 0163        target.OpenApiGroupName = source.OpenApiGroupName;
 0164        target.MaxDegreeOfParallelism = source.MaxDegreeOfParallelism;
 0165        target.ProbeTimeout = source.ProbeTimeout;
 0166        target.AutoRegisterEndpoint = source.AutoRegisterEndpoint;
 0167        target.DefaultScriptLanguage = source.DefaultScriptLanguage;
 168        // BUGFIX: Ensure the response content type preference is propagated when using the overload
 169        // that accepts a pre-configured HealthEndpointOptions instance. Without this line the
 170        // ResponseContentType would always fall back to Json for PowerShell Add-KrHealthEndpoint
 171        // which calls AddHealthEndpoint(host, options) internally.
 0172        target.ResponseContentType = source.ResponseContentType;
 0173        target.XmlRootElementName = source.XmlRootElementName;
 0174        target.Compress = source.Compress;
 0175    }
 0176    private static int DetermineStatusCode(ProbeStatus status, bool treatDegradedAsUnhealthy) => status switch
 0177    {
 0178        ProbeStatus.Healthy => StatusCodes.Status200OK,
 0179        ProbeStatus.Degraded when !treatDegradedAsUnhealthy => StatusCodes.Status200OK,
 0180        _ => StatusCodes.Status503ServiceUnavailable
 0181    };
 182
 183    private static void MapHealthEndpointImmediate(KestrunHost host, HealthEndpointOptions merged, MapRouteOptions mapOp
 184    {
 0185        if (host.MapExists(mapOptions.Pattern!, HttpVerb.Get))
 186        {
 0187            var message = $"Route '{mapOptions.Pattern}' (GET) already exists. Skipping health endpoint registration.";
 0188            if (merged.ThrowOnDuplicate)
 189            {
 0190                throw new InvalidOperationException(message);
 191            }
 0192            host.Logger.Warning(message);
 0193            return;
 194        }
 195
 196        // Acquire WebApplication (throws if Build() truly has not executed yet). Using host.App here allows
 197        // early AddHealthEndpoint calls before EnableConfiguration via deferred middleware.
 0198        var endpoints = host.App;
 0199        var endpointLogger = host.Logger.ForContext("HealthEndpoint", merged.Pattern);
 200
 0201        var map = endpoints.MapMethods(merged.Pattern, [HttpMethods.Get], async context =>
 0202        {
 0203            var requestTags = ExtractTags(context.Request);
 0204            var tags = requestTags.Length > 0 ? requestTags : merged.DefaultTags;
 0205            var snapshot = host.GetHealthProbesSnapshot();
 0206
 0207            var report = await HealthProbeRunner.RunAsync(
 0208                probes: snapshot,
 0209                tagFilter: tags,
 0210                perProbeTimeout: merged.ProbeTimeout,
 0211                maxDegreeOfParallelism: merged.MaxDegreeOfParallelism,
 0212                logger: endpointLogger,
 0213                ct: context.RequestAborted).ConfigureAwait(false);
 0214
 0215            var request = await KestrunRequest.NewRequest(context).ConfigureAwait(false);
 0216            var response = new KestrunResponse(request)
 0217            {
 0218                CacheControl = new CacheControlHeaderValue
 0219                {
 0220                    NoCache = true,
 0221                    NoStore = true,
 0222                    MustRevalidate = true,
 0223                    MaxAge = TimeSpan.Zero
 0224                }
 0225            };
 0226            context.Response.Headers.Pragma = "no-cache";
 0227            context.Response.Headers.Expires = "0";
 0228
 0229            var statusCode = DetermineStatusCode(report.Status, merged.TreatDegradedAsUnhealthy);
 0230            switch (merged.ResponseContentType)
 0231            {
 0232                case HealthEndpointContentType.Json:
 0233                    await response.WriteJsonResponseAsync(report, depth: 10, compress: merged.Compress, statusCode: stat
 0234                    break;
 0235                case HealthEndpointContentType.Yaml:
 0236                    await response.WriteYamlResponseAsync(report, statusCode).ConfigureAwait(false);
 0237                    break;
 0238                case HealthEndpointContentType.Xml:
 0239                    await response.WriteXmlResponseAsync(
 0240                        report,
 0241                        statusCode,
 0242                        rootElementName: merged.XmlRootElementName ?? "Response",
 0243                        compress: merged.Compress).ConfigureAwait(false);
 0244                    break;
 0245                case HealthEndpointContentType.Text:
 0246                    var text = HealthReportTextFormatter.Format(report);
 0247                    await response.WriteTextResponseAsync(text, statusCode, contentType: $"text/plain; charset={response
 0248                    break;
 0249                case HealthEndpointContentType.Auto:
 0250                default:
 0251                    await response.WriteResponseAsync(report, statusCode).ConfigureAwait(false);
 0252                    break;
 0253            }
 0254
 0255            await response.ApplyTo(context.Response).ConfigureAwait(false);
 0256        }).WithMetadata(new ScriptLanguageAttribute(ScriptLanguage.Native));
 257
 0258        host.AddMapOptions(map, mapOptions);
 0259        host._registeredRoutes[(mapOptions.Pattern!, HttpMethods.Get)] = mapOptions;
 0260        host.Logger.Information("Registered health endpoint at {Pattern}", mapOptions.Pattern);
 0261    }
 262}