From d1b837e718b0b7a60b979c0a648ee31a8558f82c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 06:42:24 -0400 Subject: [PATCH] feat(health): canonical JSON health response writer --- .../ZbHealthEndpointExtensions.cs | 31 ++++++- .../ZbHealthEndpointOptions.cs | 12 ++- .../ZbHealthResponseWriter.cs | 75 +++++++++++++++ .../ResponseWriterTests.cs | 92 +++++++++++++++++++ 4 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthResponseWriter.cs create mode 100644 ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/ResponseWriterTests.cs diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthEndpointExtensions.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthEndpointExtensions.cs index 6cc1734..780490f 100644 --- a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthEndpointExtensions.cs +++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthEndpointExtensions.cs @@ -22,8 +22,10 @@ public static class ZbHealthEndpointExtensions /// /// /// Does NOT call services.AddHealthChecks() — the caller registers probes and their tags. - /// The response writer is the framework default (plain-text status); a later task swaps in a - /// canonical JSON writer via (see ). + /// The readiness and active tiers use the canonical JSON writer + /// () unless overridden via + /// . The liveness tier runs no checks and + /// emits a minimal 200 OK body. /// /// /// The for the readiness (/health/ready) endpoint. @@ -37,17 +39,23 @@ public static class ZbHealthEndpointExtensions ArgumentNullException.ThrowIfNull(endpoints); options ??= new ZbHealthEndpointOptions(); + var responseWriter = options.ResponseWriter ?? ZbHealthWriter.WriteJsonAsync; + var ready = endpoints.MapHealthChecks(options.ReadyPath, new HealthCheckOptions { Predicate = static c => c.Tags.Contains(ZbHealthTags.Ready), + ResponseWriter = responseWriter, }).AllowAnonymous(); endpoints.MapHealthChecks(options.ActivePath, new HealthCheckOptions { Predicate = static c => c.Tags.Contains(ZbHealthTags.Active), + ResponseWriter = responseWriter, }).AllowAnonymous(); // Liveness: run no checks. The endpoint returns 200 as long as the process can respond. + // No JSON writer — the empty report would carry no useful data, so the framework default + // (a minimal plain-text body) is sufficient. endpoints.MapHealthChecks(options.LivePath, new HealthCheckOptions { Predicate = static _ => false, @@ -55,4 +63,23 @@ public static class ZbHealthEndpointExtensions return ready; } + + /// + /// Maps the three health tiers, configuring options inline. See the other + /// overload for tier semantics. + /// + /// The endpoint route builder to map onto. + /// Callback that mutates a fresh . + /// The for the readiness endpoint. + public static IEndpointConventionBuilder MapZbHealth( + this IEndpointRouteBuilder endpoints, + Action configure) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(configure); + + var options = new ZbHealthEndpointOptions(); + configure(options); + return endpoints.MapZbHealth(options); + } } diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthEndpointOptions.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthEndpointOptions.cs index 7f875c2..aaa2bac 100644 --- a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthEndpointOptions.cs +++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthEndpointOptions.cs @@ -1,8 +1,11 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; + namespace ZB.MOM.WW.Health; /// /// Options for . Lets callers override the -/// three tier route paths. The default paths match the ZB.MOM.WW health contract. +/// three tier route paths and the JSON response writer. The defaults match the ZB.MOM.WW health contract. /// public sealed class ZbHealthEndpointOptions { @@ -14,4 +17,11 @@ public sealed class ZbHealthEndpointOptions /// Path for the bare liveness tier (runs no checks; 200 while the process is up). public string LivePath { get; set; } = "/healthz"; + + /// + /// Response writer for the readiness and active tiers. Defaults to + /// (canonical JSON). The liveness tier runs no checks + /// and emits a minimal body, so this writer is not applied to it. + /// + public Func? ResponseWriter { get; set; } } diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthResponseWriter.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthResponseWriter.cs new file mode 100644 index 0000000..8cbfe26 --- /dev/null +++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ZbHealthResponseWriter.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace ZB.MOM.WW.Health; + +/// +/// Canonical JSON response writer for the ZB.MOM.WW health endpoints. +/// +/// +/// Self-contained — it has no runtime dependency on AspNetCore.HealthChecks.UI.Client; +/// the JSON shape is modelled after that library's UIResponseWriter output but written here +/// with . The body shape is: +/// +/// { +/// "status": "Healthy|Degraded|Unhealthy", +/// "totalDurationMs": 12.34, +/// "entries": { +/// "<name>": { "status": "...", "description": "...", "durationMs": 1.23 } +/// } +/// } +/// +/// The HTTP status code is left to the ASP.NET Core health-checks middleware (Healthy/Degraded → 200, +/// Unhealthy → 503); this writer only renders the body and sets Content-Type: application/json. +/// +public static class ZbHealthWriter +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + /// + /// Writes to the response as canonical ZB.MOM.WW health JSON. + /// + /// The current HTTP context. Its is written to. + /// The aggregated health report for the tier that ran. + public static Task WriteJsonAsync(HttpContext context, HealthReport report) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(report); + + context.Response.ContentType = "application/json; charset=utf-8"; + + var payload = new HealthReportDto + { + Status = report.Status.ToString(), + TotalDurationMs = report.TotalDuration.TotalMilliseconds, + Entries = report.Entries.ToDictionary( + static e => e.Key, + static e => new HealthEntryDto + { + Status = e.Value.Status.ToString(), + Description = e.Value.Description, + DurationMs = e.Value.Duration.TotalMilliseconds, + }), + }; + + return context.Response.WriteAsync(JsonSerializer.Serialize(payload, SerializerOptions)); + } + + private sealed class HealthReportDto + { + public string Status { get; init; } = string.Empty; + public double TotalDurationMs { get; init; } + public Dictionary Entries { get; init; } = new(); + } + + private sealed class HealthEntryDto + { + public string Status { get; init; } = string.Empty; + public string? Description { get; init; } + public double DurationMs { get; init; } + } +} diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/ResponseWriterTests.cs b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/ResponseWriterTests.cs new file mode 100644 index 0000000..dd2716f --- /dev/null +++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/ResponseWriterTests.cs @@ -0,0 +1,92 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.Health; + +namespace ZB.MOM.WW.Health.Tests; + +/// +/// Verifies the canonical JSON response writer (): +/// the JSON body shape, the application/json content type, and that the framework's +/// status-to-HTTP mapping (Healthy/Degraded → 200, Unhealthy → 503) is preserved when the +/// writer is wired onto the ready/active tiers by . +/// +public sealed class ResponseWriterTests +{ + private sealed class StubHealthCheck : IHealthCheck + { + private readonly HealthCheckResult _result; + + public StubHealthCheck(HealthStatus status, string? description = null) => + _result = new HealthCheckResult(status, description); + + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) => Task.FromResult(_result); + } + + private static async Task GetReadyAsync( + HealthStatus status, string description = "db reachable") + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddHealthChecks() + .AddCheck("db", new StubHealthCheck(status, description), tags: new[] { ZbHealthTags.Ready }); + + await using var app = builder.Build(); + app.MapZbHealth(); + await app.StartAsync(); + + var client = app.GetTestClient(); + return await client.GetAsync("/health/ready"); + } + + [Fact] + public async Task ReadyEndpoint_Healthy_WritesJsonBody_With200() + { + var response = await GetReadyAsync(HealthStatus.Healthy); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = doc.RootElement; + + Assert.Equal("Healthy", root.GetProperty("status").GetString()); + Assert.Equal(JsonValueKind.Number, root.GetProperty("totalDurationMs").ValueKind); + + var entries = root.GetProperty("entries"); + var db = entries.GetProperty("db"); + Assert.Equal("Healthy", db.GetProperty("status").GetString()); + Assert.Equal("db reachable", db.GetProperty("description").GetString()); + } + + [Fact] + public async Task ReadyEndpoint_Degraded_Returns200_WithDegradedStatus() + { + var response = await GetReadyAsync(HealthStatus.Degraded); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("Degraded", doc.RootElement.GetProperty("status").GetString()); + } + + [Fact] + public async Task ReadyEndpoint_Unhealthy_Returns503_WithUnhealthyStatus() + { + var response = await GetReadyAsync(HealthStatus.Unhealthy); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("Unhealthy", doc.RootElement.GetProperty("status").GetString()); + } +}