From dfaa499a3dca95f742cb394e7fb11db80d544f28 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 22 May 2026 00:15:03 -0500 Subject: [PATCH] feat: add health endpoint monitoring pattern --- .../fulfillment-health-endpoint-monitoring.md | 18 ++ docs/examples/index.md | 3 + docs/examples/toc.yml | 3 + docs/generators/health-endpoint-monitoring.md | 23 +++ docs/generators/index.md | 1 + docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + .../cloud/health-endpoint-monitoring.md | 21 +++ docs/patterns/toc.yml | 2 + .../HealthEndpoint.cs | 120 ++++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 13 ++ .../FulfillmentHealthEndpointDemo.cs | 135 ++++++++++++++ .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 ++ .../HealthEndpointMonitoringAttributes.cs | 19 ++ .../AnalyzerReleases.Unshipped.md | 3 + .../HealthEndpointMonitoringGenerator.cs | 175 ++++++++++++++++++ .../FulfillmentHealthEndpointDemoTests.cs | 116 ++++++++++++ .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 23 +++ .../HealthEndpointMonitoringGeneratorTests.cs | 121 ++++++++++++ .../HealthEndpointTests.cs | 77 ++++++++ 22 files changed, 900 insertions(+), 1 deletion(-) create mode 100644 docs/examples/fulfillment-health-endpoint-monitoring.md create mode 100644 docs/generators/health-endpoint-monitoring.md create mode 100644 docs/patterns/cloud/health-endpoint-monitoring.md create mode 100644 src/PatternKit.Core/Cloud/HealthEndpointMonitoring/HealthEndpoint.cs create mode 100644 src/PatternKit.Examples/HealthEndpointMonitoringDemo/FulfillmentHealthEndpointDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/Cloud/HealthEndpointMonitoringAttributes.cs create mode 100644 src/PatternKit.Generators/HealthEndpointMonitoring/HealthEndpointMonitoringGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/HealthEndpointMonitoringDemo/FulfillmentHealthEndpointDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/HealthEndpointMonitoringGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Cloud/HealthEndpointMonitoring/HealthEndpointTests.cs diff --git a/docs/examples/fulfillment-health-endpoint-monitoring.md b/docs/examples/fulfillment-health-endpoint-monitoring.md new file mode 100644 index 00000000..dff22ac4 --- /dev/null +++ b/docs/examples/fulfillment-health-endpoint-monitoring.md @@ -0,0 +1,18 @@ +# Fulfillment Health Endpoint Monitoring + +The fulfillment health endpoint example evaluates database reachability, message broker connectivity, and fulfillment queue depth. + +```csharp +services.AddFulfillmentHealthEndpointDemo(); + +var service = provider.GetRequiredService(); +var report = service.Evaluate(); +``` + +ASP.NET Core applications can expose the same endpoint through the route builder extension: + +```csharp +app.MapFulfillmentHealthEndpoint("/health/fulfillment"); +``` + +The example includes fluent and source-generated construction, `IServiceCollection` registration, Generic Host-friendly service composition, and ASP.NET Core minimal API integration. diff --git a/docs/examples/index.md b/docs/examples/index.md index a1074fdd..0169bfe8 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -81,6 +81,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Fulfillment Priority Queue** Shows fluent and source-generated business-priority queues with an importable `IServiceCollection` extension. See [Fulfillment Priority Queue](fulfillment-priority-queue.md). +* **Fulfillment Health Endpoint Monitoring** + Shows fluent and source-generated health checks with `IServiceCollection`, Generic Host-friendly services, and ASP.NET Core route mapping. See [Fulfillment Health Endpoint Monitoring](fulfillment-health-endpoint-monitoring.md). + * **Generated Message Envelope** Shows fluent and source-generated message envelope contracts side by side, with an importable `IServiceCollection` extension. See [Generated Message Envelope](generated-message-envelope.md). diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index d164b2f5..0e287233 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -115,6 +115,9 @@ - name: Fulfillment Pipes and Filters href: fulfillment-pipes-and-filters.md +- name: Fulfillment Health Endpoint Monitoring + href: fulfillment-health-endpoint-monitoring.md + - name: CQRS Dispatcher href: cqrs-dispatcher.md diff --git a/docs/generators/health-endpoint-monitoring.md b/docs/generators/health-endpoint-monitoring.md new file mode 100644 index 00000000..7c5a9934 --- /dev/null +++ b/docs/generators/health-endpoint-monitoring.md @@ -0,0 +1,23 @@ +# Health Endpoint Monitoring Generator + +`[GenerateHealthEndpoint]` creates a typed `HealthEndpoint` factory from static check methods. + +```csharp +[GenerateHealthEndpoint(typeof(FulfillmentHealthSnapshot), FactoryMethodName = "Create", EndpointName = "fulfillment-health")] +public static partial class FulfillmentHealthEndpoint +{ + [HealthEndpointCheck("database", Order = 1)] + private static HealthEndpointCheckResult CheckDatabase(FulfillmentHealthSnapshot snapshot) + => snapshot.DatabaseOnline + ? HealthEndpointCheckResult.HealthyCheck("database") + : HealthEndpointCheckResult.UnhealthyCheck("database", "offline"); +} +``` + +The generated factory is parameterless, so applications can register it in `IServiceCollection` and inject the endpoint into hosted services, readiness checks, or ASP.NET Core route handlers. + +Diagnostics: + +- `PKHEM001`: host type must be partial. +- `PKHEM002`: at least one health check is required. +- `PKHEM003`: health check signature is invalid. diff --git a/docs/generators/index.md b/docs/generators/index.md index 5c362639..0cc175a6 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -113,6 +113,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Circuit Breaker**](circuit-breaker.md) | Dependency isolation policy factories with open and half-open states | `[GenerateCircuitBreakerPolicy]` | | [**Bulkhead**](bulkhead.md) | Bounded concurrency and queue isolation policy factories | `[GenerateBulkheadPolicy]` | | [**Queue Load Leveling**](queue-load-leveling.md) | Bounded worker queue policy factories | `[GenerateQueueLoadLevelingPolicy]` | +| [**Health Endpoint Monitoring**](health-endpoint-monitoring.md) | Typed service health endpoint factories | `[GenerateHealthEndpoint]` | | [**Priority Queue**](priority-queue.md) | Business-priority queue factories | `[GeneratePriorityQueue]` | | [**Cache-Aside**](cache-aside.md) | Read-through cache policy factories with TTL and cache predicates | `[GenerateCacheAsidePolicy]` | | [**Rate Limiting**](rate-limiting.md) | Key-partitioned fixed-window rate limit policy factories | `[GenerateRateLimitPolicy]` | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index a7bb1ebc..5960d93d 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -67,6 +67,9 @@ - name: Flyweight href: flyweight.md +- name: Health Endpoint Monitoring + href: health-endpoint-monitoring.md + - name: Interpreter href: interpreter.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 86e7b774..2d02213e 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -78,6 +78,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Cloud Architecture | Circuit Breaker | `CircuitBreakerPolicy` | Circuit Breaker generator | | Cloud Architecture | Bulkhead | `BulkheadPolicy` | Bulkhead generator | | Cloud Architecture | Queue-Based Load Leveling | `QueueLoadLevelingPolicy` | Queue Load Leveling generator | +| Cloud Architecture | Health Endpoint Monitoring | `HealthEndpoint` | Health Endpoint Monitoring generator | | Cloud Architecture | Priority Queue | `PriorityQueuePolicy` | Priority Queue generator | | Cloud Architecture | Cache-Aside | `CacheAsidePolicy` | Cache-Aside generator | | Cloud Architecture | Rate Limiting | `RateLimitPolicy` | Rate Limiting generator | diff --git a/docs/patterns/cloud/health-endpoint-monitoring.md b/docs/patterns/cloud/health-endpoint-monitoring.md new file mode 100644 index 00000000..6c782022 --- /dev/null +++ b/docs/patterns/cloud/health-endpoint-monitoring.md @@ -0,0 +1,21 @@ +# Health Endpoint Monitoring + +Health Endpoint Monitoring exposes a typed status endpoint that evaluates application dependencies and returns a deterministic health report. + +```csharp +var endpoint = HealthEndpoint + .Create("fulfillment-health") + .WithCheck("database", snapshot => snapshot.DatabaseOnline + ? HealthEndpointCheckResult.HealthyCheck("database") + : HealthEndpointCheckResult.UnhealthyCheck("database", "offline")) + .WithCheck("queue-depth", snapshot => snapshot.QueueDepth <= 100 + ? HealthEndpointCheckResult.HealthyCheck("queue-depth") + : HealthEndpointCheckResult.UnhealthyCheck("queue-depth", "backlog above target")) + .Build(); + +var report = endpoint.Evaluate(snapshot); +``` + +Use it when service health needs to be composed from multiple business and infrastructure checks before it is exposed to load balancers, orchestrators, Generic Host startup validation, or ASP.NET Core endpoints. + +The source-generated path uses `[GenerateHealthEndpoint]` and `[HealthEndpointCheck]`. Import the fulfillment example through `AddFulfillmentHealthEndpointDemo()`, map it through `MapFulfillmentHealthEndpoint()`, or include it in `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index c1174150..405c8cae 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -355,6 +355,8 @@ href: cloud/bulkhead.md - name: Queue-Based Load Leveling href: cloud/queue-load-leveling.md + - name: Health Endpoint Monitoring + href: cloud/health-endpoint-monitoring.md - name: Priority Queue href: cloud/priority-queue.md - name: Cache-Aside diff --git a/src/PatternKit.Core/Cloud/HealthEndpointMonitoring/HealthEndpoint.cs b/src/PatternKit.Core/Cloud/HealthEndpointMonitoring/HealthEndpoint.cs new file mode 100644 index 00000000..a2996865 --- /dev/null +++ b/src/PatternKit.Core/Cloud/HealthEndpointMonitoring/HealthEndpoint.cs @@ -0,0 +1,120 @@ +namespace PatternKit.Cloud.HealthEndpointMonitoring; + +public sealed class HealthEndpointCheckResult +{ + private HealthEndpointCheckResult(string name, bool healthy, string message) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Health check name is required.", nameof(name)); + + Name = name; + Healthy = healthy; + Message = message ?? string.Empty; + } + + public string Name { get; } + + public bool Healthy { get; } + + public string Message { get; } + + public static HealthEndpointCheckResult HealthyCheck(string name, string message = "") + => new(name, true, message); + + public static HealthEndpointCheckResult UnhealthyCheck(string name, string message) + => new(name, false, message); +} + +public sealed class HealthEndpointReport +{ + public HealthEndpointReport(string endpointName, IReadOnlyList checks) + { + if (string.IsNullOrWhiteSpace(endpointName)) + throw new ArgumentException("Health endpoint name is required.", nameof(endpointName)); + + EndpointName = endpointName; + Checks = checks ?? throw new ArgumentNullException(nameof(checks)); + Healthy = checks.All(static check => check.Healthy); + PassedCount = checks.Count(static check => check.Healthy); + FailedCount = checks.Count - PassedCount; + } + + public string EndpointName { get; } + + public bool Healthy { get; } + + public int PassedCount { get; } + + public int FailedCount { get; } + + public IReadOnlyList Checks { get; } +} + +public sealed class HealthEndpoint +{ + private readonly IReadOnlyList _checks; + + private HealthEndpoint(string name, IReadOnlyList checks) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Health endpoint name is required.", nameof(name)); + + if (checks is null) + throw new ArgumentNullException(nameof(checks)); + if (checks.Count == 0) + throw new InvalidOperationException("Health endpoint requires at least one check."); + + Name = name; + _checks = checks; + } + + public string Name { get; } + + public HealthEndpointReport Evaluate(TContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + var results = new List(_checks.Count); + foreach (var check in _checks) + { + var result = check.Evaluate(context) ?? throw new InvalidOperationException($"Health check '{check.Name}' returned null."); + results.Add(result); + } + + return new(Name, results); + } + + public static Builder Create(string name = "health-endpoint") => new(name); + + public sealed class Builder + { + private readonly string _name; + private readonly List _checks = []; + + internal Builder(string name) => _name = name; + + public Builder WithCheck(string name, Func check) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Health check name is required.", nameof(name)); + if (check is null) + throw new ArgumentNullException(nameof(check)); + + _checks.Add(new(name, check)); + return this; + } + + public HealthEndpoint Build() => new(_name, _checks.ToArray()); + } + + private sealed class ConfiguredCheck + { + public ConfiguredCheck(string name, Func evaluate) + => (Name, Evaluate) = (name, evaluate); + + public string Name { get; } + + public Func Evaluate { get; } + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 88b6f415..e7d1baae 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using PatternKit.Cloud.Bulkhead; using PatternKit.Cloud.CacheAside; using PatternKit.Cloud.CircuitBreaker; +using PatternKit.Cloud.HealthEndpointMonitoring; using PatternKit.Cloud.PriorityQueue; using PatternKit.Cloud.RateLimiting; using PatternKit.Cloud.QueueLoadLeveling; @@ -35,6 +36,7 @@ using PatternKit.Examples.FlyweightDemo; using PatternKit.Examples.Generators.Builders.CorporateApplicationBuilderDemo; using PatternKit.Examples.Generators.Visitors; +using PatternKit.Examples.HealthEndpointMonitoringDemo; using PatternKit.Examples.IdentityMapDemo; using PatternKit.Examples.MaterializedViewDemo; using PatternKit.Examples.MementoDemo; @@ -187,6 +189,7 @@ public sealed record InventoryRetryExample(RetryPolicy Policy public sealed record FulfillmentCircuitBreakerExample(CircuitBreakerPolicy Policy, FulfillmentCircuitBreakerService Service); public sealed record ShippingBulkheadExample(BulkheadPolicy Policy, ShippingBulkheadService Service); public sealed record FulfillmentQueueLoadLevelingExample(QueueLoadLevelingPolicy Policy, FulfillmentQueueLoadLevelingService Service); +public sealed record FulfillmentHealthEndpointExample(HealthEndpoint Endpoint, FulfillmentHealthEndpointService Service); public sealed record FulfillmentPriorityQueueExample(PriorityQueuePolicy Queue, FulfillmentPriorityQueueService Service); public sealed record ProductCatalogCacheAsideExample(CacheAsidePolicy Policy, ProductCatalogCacheAsideService Service); public sealed record ProductSearchRateLimitingExample(RateLimitPolicy Policy, ProductSearchRateLimitService Service); @@ -272,6 +275,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddFulfillmentCircuitBreakerExample() .AddShippingBulkheadExample() .AddFulfillmentQueueLoadLevelingExample() + .AddFulfillmentHealthEndpointExample() .AddFulfillmentPriorityQueueExample() .AddProductCatalogCacheAsideExample() .AddProductSearchRateLimitingExample() @@ -941,6 +945,15 @@ public static IServiceCollection AddFulfillmentPriorityQueueExample(this IServic return services.RegisterExample("Fulfillment Priority Queue", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddFulfillmentHealthEndpointExample(this IServiceCollection services) + { + services.AddFulfillmentHealthEndpointDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Fulfillment Health Endpoint Monitoring", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore); + } + public static IServiceCollection AddProductCatalogCacheAsideExample(this IServiceCollection services) { services.AddProductCatalogCacheAsideDemo(); diff --git a/src/PatternKit.Examples/HealthEndpointMonitoringDemo/FulfillmentHealthEndpointDemo.cs b/src/PatternKit.Examples/HealthEndpointMonitoringDemo/FulfillmentHealthEndpointDemo.cs new file mode 100644 index 00000000..b42add73 --- /dev/null +++ b/src/PatternKit.Examples/HealthEndpointMonitoringDemo/FulfillmentHealthEndpointDemo.cs @@ -0,0 +1,135 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.HealthEndpointMonitoring; +using PatternKit.Generators.HealthEndpointMonitoring; + +namespace PatternKit.Examples.HealthEndpointMonitoringDemo; + +public sealed record FulfillmentHealthSnapshot(bool DatabaseOnline, bool BrokerOnline, int QueueDepth); + +public sealed record FulfillmentHealthSummary(string EndpointName, bool Healthy, int PassedCount, int FailedCount); + +public interface IFulfillmentHealthSnapshotProvider +{ + FulfillmentHealthSnapshot GetSnapshot(); +} + +public sealed class StaticFulfillmentHealthSnapshotProvider(FulfillmentHealthSnapshot snapshot) : IFulfillmentHealthSnapshotProvider +{ + public FulfillmentHealthSnapshot GetSnapshot() => snapshot; +} + +public sealed class FulfillmentHealthEndpointService( + HealthEndpoint endpoint, + IFulfillmentHealthSnapshotProvider snapshots) +{ + public HealthEndpointReport Evaluate() => endpoint.Evaluate(snapshots.GetSnapshot()); + + public FulfillmentHealthSummary Summarize() + { + var report = Evaluate(); + return new(report.EndpointName, report.Healthy, report.PassedCount, report.FailedCount); + } +} + +public static class FulfillmentHealthEndpoints +{ + public static HealthEndpoint CreateFluent() + => HealthEndpoint.Create("fulfillment-health") + .WithCheck("database", CheckDatabase) + .WithCheck("message-broker", CheckBroker) + .WithCheck("queue-depth", CheckQueueDepth) + .Build(); + + public static HealthEndpointCheckResult CheckDatabase(FulfillmentHealthSnapshot snapshot) + => snapshot.DatabaseOnline + ? HealthEndpointCheckResult.HealthyCheck("database", "database reachable") + : HealthEndpointCheckResult.UnhealthyCheck("database", "database offline"); + + public static HealthEndpointCheckResult CheckBroker(FulfillmentHealthSnapshot snapshot) + => snapshot.BrokerOnline + ? HealthEndpointCheckResult.HealthyCheck("message-broker", "broker connected") + : HealthEndpointCheckResult.UnhealthyCheck("message-broker", "broker disconnected"); + + public static HealthEndpointCheckResult CheckQueueDepth(FulfillmentHealthSnapshot snapshot) + => snapshot.QueueDepth <= 100 + ? HealthEndpointCheckResult.HealthyCheck("queue-depth", "queue within target") + : HealthEndpointCheckResult.UnhealthyCheck("queue-depth", "queue backlog above target"); +} + +[GenerateHealthEndpoint(typeof(FulfillmentHealthSnapshot), FactoryMethodName = "Create", EndpointName = "fulfillment-health")] +public static partial class GeneratedFulfillmentHealthEndpoint +{ + [HealthEndpointCheck("database", Order = 1)] + private static HealthEndpointCheckResult CheckDatabase(FulfillmentHealthSnapshot snapshot) + => FulfillmentHealthEndpoints.CheckDatabase(snapshot); + + [HealthEndpointCheck("message-broker", Order = 2)] + private static HealthEndpointCheckResult CheckBroker(FulfillmentHealthSnapshot snapshot) + => FulfillmentHealthEndpoints.CheckBroker(snapshot); + + [HealthEndpointCheck("queue-depth", Order = 3)] + private static HealthEndpointCheckResult CheckQueueDepth(FulfillmentHealthSnapshot snapshot) + => FulfillmentHealthEndpoints.CheckQueueDepth(snapshot); +} + +public sealed class FulfillmentHealthEndpointDemoRunner(FulfillmentHealthEndpointService service) +{ + public FulfillmentHealthSummary RunGenerated() => service.Summarize(); + + public static FulfillmentHealthSummary RunFluent() + => RunWith(FulfillmentHealthEndpoints.CreateFluent(), HealthySnapshot()); + + public static FulfillmentHealthSummary RunGeneratedStatic() + => RunWith(GeneratedFulfillmentHealthEndpoint.Create(), HealthySnapshot()); + + public static FulfillmentHealthSnapshot HealthySnapshot() + => new(DatabaseOnline: true, BrokerOnline: true, QueueDepth: 8); + + public static FulfillmentHealthSnapshot DegradedSnapshot() + => new(DatabaseOnline: true, BrokerOnline: false, QueueDepth: 175); + + private static FulfillmentHealthSummary RunWith(HealthEndpoint endpoint, FulfillmentHealthSnapshot snapshot) + { + var service = new FulfillmentHealthEndpointService(endpoint, new StaticFulfillmentHealthSnapshotProvider(snapshot)); + return service.Summarize(); + } +} + +public static class FulfillmentHealthEndpointServiceCollectionExtensions +{ + public static IServiceCollection AddFulfillmentHealthEndpointDemo( + this IServiceCollection services, + Func? snapshotFactory = null) + { + services.AddSingleton(static _ => GeneratedFulfillmentHealthEndpoint.Create()); + services.AddSingleton(sp => new StaticFulfillmentHealthSnapshotProvider( + snapshotFactory?.Invoke(sp) ?? FulfillmentHealthEndpointDemoRunner.HealthySnapshot())); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} + +public static class FulfillmentHealthEndpointRouteBuilderExtensions +{ + public static IEndpointRouteBuilder MapFulfillmentHealthEndpoint( + this IEndpointRouteBuilder endpoints, + string pattern = "/health/fulfillment") + { + endpoints.MapGet(pattern, static (FulfillmentHealthEndpointService service) => + { + var report = service.Evaluate(); + return report.Healthy + ? Results.Ok(report) + : Results.Problem( + title: "Fulfillment health check failed", + detail: $"{report.FailedCount} health check(s) failed.", + statusCode: StatusCodes.Status503ServiceUnavailable); + }).WithName("FulfillmentHealthEndpoint"); + + return endpoints; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index b448c318..250a18dd 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -664,6 +664,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["Queue-Based Load Leveling"], ["bounded worker queue", "source-generated policy factory", "DI composition"]), + Descriptor( + "Fulfillment Health Endpoint Monitoring", + "src/PatternKit.Examples/HealthEndpointMonitoringDemo/FulfillmentHealthEndpointDemo.cs", + "test/PatternKit.Examples.Tests/HealthEndpointMonitoringDemo/FulfillmentHealthEndpointDemoTests.cs", + "docs/examples/fulfillment-health-endpoint-monitoring.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore, + ["Health Endpoint Monitoring"], + ["dependency health report", "source-generated endpoint factory", "DI and ASP.NET Core composition"]), Descriptor( "Fulfillment Priority Queue", "src/PatternKit.Examples/PriorityQueueDemo/FulfillmentPriorityQueueDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 228562e1..5df6e488 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -805,6 +805,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/QueueLoadLevelingDemo/FulfillmentQueueLoadLevelingDemoTests.cs", ["fluent bounded worker queue", "generated queue load leveling policy", "DI-importable fulfillment queue example"]), + Pattern("Health Endpoint Monitoring", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/health-endpoint-monitoring.md", + "src/PatternKit.Core/Cloud/HealthEndpointMonitoring/HealthEndpoint.cs", + "test/PatternKit.Tests/Cloud/HealthEndpointMonitoring/HealthEndpointTests.cs", + "docs/generators/health-endpoint-monitoring.md", + "src/PatternKit.Generators/HealthEndpointMonitoring/HealthEndpointMonitoringGenerator.cs", + "test/PatternKit.Generators.Tests/HealthEndpointMonitoringGeneratorTests.cs", + null, + "docs/examples/fulfillment-health-endpoint-monitoring.md", + "src/PatternKit.Examples/HealthEndpointMonitoringDemo/FulfillmentHealthEndpointDemo.cs", + "test/PatternKit.Examples.Tests/HealthEndpointMonitoringDemo/FulfillmentHealthEndpointDemoTests.cs", + ["fluent health endpoint", "generated health endpoint factory", "DI and ASP.NET Core importable health example"]), + Pattern("Priority Queue", PatternFamily.CloudArchitecture, "docs/patterns/cloud/priority-queue.md", "src/PatternKit.Core/Cloud/PriorityQueue/PriorityQueuePolicy.cs", diff --git a/src/PatternKit.Generators.Abstractions/Cloud/HealthEndpointMonitoringAttributes.cs b/src/PatternKit.Generators.Abstractions/Cloud/HealthEndpointMonitoringAttributes.cs new file mode 100644 index 00000000..7dc21e68 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Cloud/HealthEndpointMonitoringAttributes.cs @@ -0,0 +1,19 @@ +namespace PatternKit.Generators.HealthEndpointMonitoring; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateHealthEndpointAttribute(Type contextType) : Attribute +{ + public Type ContextType { get; } = contextType ?? throw new ArgumentNullException(nameof(contextType)); + + public string FactoryMethodName { get; set; } = "Create"; + + public string EndpointName { get; set; } = "health-endpoint"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class HealthEndpointCheckAttribute(string? name = null) : Attribute +{ + public string? Name { get; } = name; + + public int Order { get; set; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 370e901f..d335bba4 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -299,6 +299,9 @@ PKECS001 | PatternKit.Generators.Cloud | Error | External Configuration Store ho PKECS002 | PatternKit.Generators.Cloud | Error | External Configuration Store loader is invalid. PKECS003 | PatternKit.Generators.Cloud | Error | External Configuration Store validator signature is invalid. PKECS004 | PatternKit.Generators.Cloud | Error | External Configuration Store validator order is duplicated. +PKHEM001 | PatternKit.Generators.HealthEndpointMonitoring | Error | Health Endpoint host must be partial. +PKHEM002 | PatternKit.Generators.HealthEndpointMonitoring | Error | Health Endpoint checks are missing. +PKHEM003 | PatternKit.Generators.HealthEndpointMonitoring | Error | Health Endpoint check signature is invalid. PKMS001 | PatternKit.Generators.Messaging | Error | Message store type must be partial. PKMS002 | PatternKit.Generators.Messaging | Error | Message store identity signature is invalid. PKMS003 | PatternKit.Generators.Messaging | Error | Message store retention signature is invalid. diff --git a/src/PatternKit.Generators/HealthEndpointMonitoring/HealthEndpointMonitoringGenerator.cs b/src/PatternKit.Generators/HealthEndpointMonitoring/HealthEndpointMonitoringGenerator.cs new file mode 100644 index 00000000..a2ac1bef --- /dev/null +++ b/src/PatternKit.Generators/HealthEndpointMonitoring/HealthEndpointMonitoringGenerator.cs @@ -0,0 +1,175 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.HealthEndpointMonitoring; + +[Generator] +public sealed class HealthEndpointMonitoringGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.HealthEndpointMonitoring.GenerateHealthEndpointAttribute"; + private const string CheckAttributeName = "PatternKit.Generators.HealthEndpointMonitoring.HealthEndpointCheckAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKHEM001", "Health Endpoint host must be partial", + "Type '{0}' is marked with [GenerateHealthEndpoint] but is not declared as partial", + "PatternKit.Generators.HealthEndpointMonitoring", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingChecks = new( + "PKHEM002", "Health Endpoint checks are missing", + "Health Endpoint type '{0}' must declare at least one [HealthEndpointCheck] method", + "PatternKit.Generators.HealthEndpointMonitoring", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidCheck = new( + "PKHEM003", "Health Endpoint check signature is invalid", + "Health Endpoint check '{0}' must be static and return HealthEndpointCheckResult with one TContext parameter", + "PatternKit.Generators.HealthEndpointMonitoring", DiagnosticSeverity.Error, true); + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => a.AttributeClass?.ToDisplayString() == AttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var contextType = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + if (contextType is null) + return; + + var checks = type.GetMembers().OfType() + .Select(static method => new CheckCandidate( + method, + method.GetAttributes().FirstOrDefault(static attr => attr.AttributeClass?.ToDisplayString() == CheckAttributeName))) + .Where(static candidate => candidate.Attribute is not null) + .ToArray(); + + if (checks.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create(MissingChecks, node.Identifier.GetLocation(), type.Name)); + return; + } + + foreach (var check in checks) + { + if (!IsCheck(check.Method, contextType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidCheck, check.Method.Locations.FirstOrDefault(), check.Method.Name)); + return; + } + } + + var configuredChecks = checks + .Select(static check => new ConfiguredCheck( + check.Method.Name, + GetCheckName(check.Attribute!, check.Method.Name), + GetNamedInt(check.Attribute!, "Order") ?? 0)) + .OrderBy(static check => check.Order) + .ThenBy(static check => check.MethodName) + .ToArray(); + + context.AddSource($"{type.Name}.HealthEndpoint.g.cs", SourceText.From(GenerateSource( + type, + contextType, + configuredChecks, + GetNamedString(attribute, "FactoryMethodName") ?? "Create", + GetNamedString(attribute, "EndpointName") ?? "health-endpoint"), Encoding.UTF8)); + } + + private static bool IsCheck(IMethodSymbol method, INamedTypeSymbol contextType) + => method.IsStatic && + method.ReturnType.ToDisplayString() == "PatternKit.Cloud.HealthEndpointMonitoring.HealthEndpointCheckResult" && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, contextType); + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol contextType, + ConfiguredCheck[] checks, + string factoryMethodName, + string endpointName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var contextTypeName = contextType.ToDisplayString(TypeFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Cloud.HealthEndpointMonitoring.HealthEndpoint<").Append(contextTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" return global::PatternKit.Cloud.HealthEndpointMonitoring.HealthEndpoint<").Append(contextTypeName).Append(">.Create(\"").Append(Escape(endpointName)).AppendLine("\")"); + foreach (var check in checks) + sb.Append(" .WithCheck(\"").Append(Escape(check.Name)).Append("\", ").Append(check.MethodName).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string GetCheckName(AttributeData attribute, string fallback) + => attribute.ConstructorArguments.Length == 1 && attribute.ConstructorArguments[0].Value is string name && !string.IsNullOrWhiteSpace(name) + ? name + : fallback; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static int? GetNamedInt(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as int?; + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + + private sealed record CheckCandidate(IMethodSymbol Method, AttributeData? Attribute); + + private sealed record ConfiguredCheck(string MethodName, string Name, int Order); +} diff --git a/test/PatternKit.Examples.Tests/HealthEndpointMonitoringDemo/FulfillmentHealthEndpointDemoTests.cs b/test/PatternKit.Examples.Tests/HealthEndpointMonitoringDemo/FulfillmentHealthEndpointDemoTests.cs new file mode 100644 index 00000000..2dd27790 --- /dev/null +++ b/test/PatternKit.Examples.Tests/HealthEndpointMonitoringDemo/FulfillmentHealthEndpointDemoTests.cs @@ -0,0 +1,116 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.HealthEndpointMonitoringDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.HealthEndpointMonitoringDemo; + +[Feature("Fulfillment health endpoint monitoring example")] +public sealed class FulfillmentHealthEndpointDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated health endpoints report fulfillment health")] + [Fact] + public Task Fluent_And_Generated_Health_Endpoints_Report_Fulfillment_Health() + => Given("fulfillment health endpoint examples", () => new + { + Fluent = FulfillmentHealthEndpointDemoRunner.RunFluent(), + Generated = FulfillmentHealthEndpointDemoRunner.RunGeneratedStatic() + }) + .Then("both paths report a healthy fulfillment dependency set", result => + { + ScenarioExpect.True(result.Fluent.Healthy); + ScenarioExpect.True(result.Generated.Healthy); + ScenarioExpect.Equal("fulfillment-health", result.Generated.EndpointName); + ScenarioExpect.Equal(3, result.Generated.PassedCount); + }) + .AssertPassed(); + + [Scenario("Health endpoint demo reports degraded dependencies")] + [Fact] + public Task Health_Endpoint_Demo_Reports_Degraded_Dependencies() + => Given("a service provider with degraded fulfillment dependencies", () => + { + var services = new ServiceCollection(); + services.AddFulfillmentHealthEndpointDemo(_ => FulfillmentHealthEndpointDemoRunner.DegradedSnapshot()); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("evaluating the generated health endpoint", provider => + { + using (provider) + return provider.GetRequiredService().Evaluate(); + }) + .Then("the report identifies failed checks", report => + { + ScenarioExpect.False(report.Healthy); + ScenarioExpect.Equal(1, report.PassedCount); + ScenarioExpect.Equal(2, report.FailedCount); + ScenarioExpect.Contains(report.Checks, static check => check.Name == "message-broker" && !check.Healthy); + }) + .AssertPassed(); + + [Scenario("Health endpoint demo is importable through IServiceCollection")] + [Fact] + public Task Health_Endpoint_Demo_Is_Importable_Through_IServiceCollection() + => Given("an importing app service provider", () => + { + var services = new ServiceCollection(); + services.AddFulfillmentHealthEndpointDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving and running the service", provider => + { + using (provider) + return provider.GetRequiredService().Summarize(); + }) + .Then("the service reports healthy fulfillment dependencies", result => + { + ScenarioExpect.True(result.Healthy); + ScenarioExpect.Equal(0, result.FailedCount); + }) + .AssertPassed(); + + [Scenario("Aggregate examples import health endpoint demo")] + [Fact] + public Task Aggregate_Examples_Import_Health_Endpoint_Demo() + => Given("a PatternKit examples service provider", () => + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving the aggregate health endpoint example", provider => + { + using (provider) + return provider.GetRequiredService(); + }) + .Then("the aggregate example exposes the endpoint and service", example => + { + ScenarioExpect.Equal("fulfillment-health", example.Endpoint.Name); + ScenarioExpect.NotNull(example.Service); + }) + .AssertPassed(); + + [Scenario("Health endpoint demo maps to ASP.NET Core endpoints")] + [Fact] + public Task Health_Endpoint_Demo_Maps_To_AspNetCore_Endpoints() + => Given("an ASP.NET Core application", () => + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddFulfillmentHealthEndpointDemo(); + return builder.Build(); + }) + .When("mapping the fulfillment health endpoint", app => + { + using (app) + { + app.MapFulfillmentHealthEndpoint(); + return app.Services.GetRequiredService().Summarize(); + } + }) + .Then("the mapped app can resolve the endpoint service", result => + ScenarioExpect.True(result.Healthy)) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index bf8a24e0..c83bbe68 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -72,6 +72,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Circuit Breaker", "Bulkhead", "Queue-Based Load Leveling", + "Health Endpoint Monitoring", "Priority Queue", "Cache-Aside", "Rate Limiting", @@ -133,7 +134,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() { ScenarioExpect.Equal(27, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); - ScenarioExpect.Equal(8, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); + ScenarioExpect.Equal(9, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(15, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index cc828114..d9b7e014 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -19,6 +19,7 @@ using PatternKit.Generators.IdentityMap; using PatternKit.Generators.Interpreter; using PatternKit.Generators.Iterator; +using PatternKit.Generators.HealthEndpointMonitoring; using PatternKit.Generators.MaterializedViews; using PatternKit.Generators.Messaging; using PatternKit.Generators.Observer; @@ -203,6 +204,8 @@ private enum TestTrigger { typeof(PrototypeStrategyAttribute), AttributeTargets.Property | AttributeTargets.Field, false, false }, { typeof(GenerateProxyAttribute), AttributeTargets.Interface | AttributeTargets.Class, false, false }, { typeof(ProxyIgnoreAttribute), AttributeTargets.Method | AttributeTargets.Property, false, false }, + { typeof(GenerateHealthEndpointAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(HealthEndpointCheckAttribute), AttributeTargets.Method, false, false }, { typeof(GeneratePriorityQueueAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(PriorityQueuePrioritySelectorAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateQueueLoadLevelingPolicyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -410,6 +413,26 @@ public void PriorityQueue_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.IsType(new PriorityQueuePrioritySelectorAttribute()); } + [Scenario("Health Endpoint Attributes Expose Defaults And Configuration")] + [Fact] + public void HealthEndpoint_Attributes_Expose_Defaults_And_Configuration() + { + var endpoint = new GenerateHealthEndpointAttribute(typeof(string)) + { + FactoryMethodName = "BuildFulfillmentHealth", + EndpointName = "fulfillment-health" + }; + var check = new HealthEndpointCheckAttribute("database") { Order = 2 }; + + ScenarioExpect.Equal(typeof(string), endpoint.ContextType); + ScenarioExpect.Equal("BuildFulfillmentHealth", endpoint.FactoryMethodName); + ScenarioExpect.Equal("fulfillment-health", endpoint.EndpointName); + ScenarioExpect.Equal("database", check.Name); + ScenarioExpect.Equal(2, check.Order); + ScenarioExpect.Null(new HealthEndpointCheckAttribute().Name); + ScenarioExpect.Throws(() => new GenerateHealthEndpointAttribute(null!)); + } + [Scenario("Queue Load Leveling Attributes Expose Defaults And Configuration")] [Fact] public void QueueLoadLeveling_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/HealthEndpointMonitoringGeneratorTests.cs b/test/PatternKit.Generators.Tests/HealthEndpointMonitoringGeneratorTests.cs new file mode 100644 index 00000000..72e14e79 --- /dev/null +++ b/test/PatternKit.Generators.Tests/HealthEndpointMonitoringGeneratorTests.cs @@ -0,0 +1,121 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Cloud.HealthEndpointMonitoring; +using PatternKit.Generators.HealthEndpointMonitoring; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Health Endpoint Monitoring generator")] +public sealed partial class HealthEndpointMonitoringGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates health endpoint factory")] + [Fact] + public Task Generates_Health_Endpoint_Factory() + => Given("a health endpoint declaration", () => Compile(""" + using PatternKit.Cloud.HealthEndpointMonitoring; + using PatternKit.Generators.HealthEndpointMonitoring; + namespace Demo; + public sealed record FulfillmentHealth(bool DatabaseOnline, int QueueDepth); + [GenerateHealthEndpoint(typeof(FulfillmentHealth), FactoryMethodName = "Build", EndpointName = "fulfillment-health")] + public static partial class FulfillmentHealthEndpoint + { + [HealthEndpointCheck("database", Order = 2)] + private static HealthEndpointCheckResult Database(FulfillmentHealth health) => HealthEndpointCheckResult.HealthyCheck("database"); + + [HealthEndpointCheck("queue-depth", Order = 1)] + private static HealthEndpointCheckResult Queue(FulfillmentHealth health) => HealthEndpointCheckResult.HealthyCheck("queue-depth"); + } + """)) + .Then("the generated source creates the configured endpoint", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("HealthEndpoint.Create(\"fulfillment-health\")", source); + ScenarioExpect.True(source.IndexOf(".WithCheck(\"queue-depth\", Queue)", StringComparison.Ordinal) < source.IndexOf(".WithCheck(\"database\", Database)", StringComparison.Ordinal)); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid health endpoint declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Health_Endpoint_Declarations() + => Given("invalid health endpoint declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.HealthEndpointMonitoring; + [GenerateHealthEndpoint(typeof(string))] + public static class HealthEndpointHost; + """), + Compile(""" + using PatternKit.Generators.HealthEndpointMonitoring; + [GenerateHealthEndpoint(typeof(string))] + public static partial class HealthEndpointHost; + """), + Compile(""" + using PatternKit.Generators.HealthEndpointMonitoring; + [GenerateHealthEndpoint(typeof(string))] + public static partial class HealthEndpointHost + { + [HealthEndpointCheck] + private static string Check(string value) => value; + } + """) + }) + .Then("diagnostics identify the invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKHEM001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKHEM002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKHEM003"); + }) + .AssertPassed(); + + [Scenario("Generates default check names with escaped endpoint names")] + [Fact] + public Task Generates_Default_Check_Names_With_Escaped_Endpoint_Names() + => Given("a health endpoint declaration with default check name", () => Compile(""" + using PatternKit.Cloud.HealthEndpointMonitoring; + using PatternKit.Generators.HealthEndpointMonitoring; + namespace Demo; + [GenerateHealthEndpoint(typeof(string), EndpointName = "health\"" + "\\endpoint")] + internal partial struct HealthDefaults + { + [HealthEndpointCheck] + private static HealthEndpointCheckResult Value(string value) => HealthEndpointCheckResult.HealthyCheck("value"); + } + """)) + .Then("the generated source preserves configuration", result => + { + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Contains("internal partial struct HealthDefaults", source); + ScenarioExpect.Contains("Create(\"health\\\"\\\\endpoint\")", source); + ScenarioExpect.Contains(".WithCheck(\"Value\", Value)", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "HealthEndpointMonitoringGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(HealthEndpoint<>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new HealthEndpointMonitoringGenerator(), out var run, out var updated); + var result = run.Results.Single(); + var emit = updated.Emit(Stream.Null); + return new GeneratorResult( + result.Diagnostics.ToArray(), + result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray(), + emit.Success, + emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray()); + } + + private sealed record GeneratorResult( + IReadOnlyList Diagnostics, + IReadOnlyList GeneratedSources, + bool EmitSuccess, + IReadOnlyList EmitDiagnostics); +} diff --git a/test/PatternKit.Tests/Cloud/HealthEndpointMonitoring/HealthEndpointTests.cs b/test/PatternKit.Tests/Cloud/HealthEndpointMonitoring/HealthEndpointTests.cs new file mode 100644 index 00000000..f179739b --- /dev/null +++ b/test/PatternKit.Tests/Cloud/HealthEndpointMonitoring/HealthEndpointTests.cs @@ -0,0 +1,77 @@ +using PatternKit.Cloud.HealthEndpointMonitoring; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Cloud.HealthEndpointMonitoring; + +[Feature("Health Endpoint Monitoring")] +public sealed class HealthEndpointTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Health endpoint reports healthy when every check passes")] + [Fact] + public Task Health_Endpoint_Reports_Healthy_When_Every_Check_Passes() + => Given("a health endpoint", () => HealthEndpoint.Create("fulfillment-health") + .WithCheck("database", static health => health.DatabaseOnline + ? HealthEndpointCheckResult.HealthyCheck("database", "reachable") + : HealthEndpointCheckResult.UnhealthyCheck("database", "offline")) + .WithCheck("broker", static health => health.BrokerOnline + ? HealthEndpointCheckResult.HealthyCheck("broker", "connected") + : HealthEndpointCheckResult.UnhealthyCheck("broker", "disconnected")) + .Build()) + .When("all dependencies are available", endpoint => endpoint.Evaluate(new(true, true, 4))) + .Then("the report is healthy", report => + { + ScenarioExpect.Equal("fulfillment-health", report.EndpointName); + ScenarioExpect.True(report.Healthy); + ScenarioExpect.Equal(2, report.PassedCount); + ScenarioExpect.Equal(0, report.FailedCount); + }) + .AssertPassed(); + + [Scenario("Health endpoint reports failed checks")] + [Fact] + public Task Health_Endpoint_Reports_Failed_Checks() + => Given("a health endpoint", () => HealthEndpoint.Create() + .WithCheck("queue-depth", static health => health.QueueDepth <= 10 + ? HealthEndpointCheckResult.HealthyCheck("queue-depth", "within target") + : HealthEndpointCheckResult.UnhealthyCheck("queue-depth", "backlog too deep")) + .Build()) + .When("the queue is overloaded", endpoint => endpoint.Evaluate(new(true, true, 25))) + .Then("the report captures the failed dependency", report => + { + ScenarioExpect.False(report.Healthy); + ScenarioExpect.Equal(0, report.PassedCount); + ScenarioExpect.Equal(1, report.FailedCount); + ScenarioExpect.Equal("backlog too deep", ScenarioExpect.Single(report.Checks).Message); + }) + .AssertPassed(); + + [Scenario("Health endpoint validates configuration and context")] + [Fact] + public Task Health_Endpoint_Validates_Configuration_And_Context() + => Given("invalid health endpoint inputs", () => true) + .Then("invalid endpoint names are rejected", _ => + ScenarioExpect.Throws(() => HealthEndpoint.Create("").WithCheck("database", static _ => HealthEndpointCheckResult.HealthyCheck("database")).Build())) + .And("missing checks are rejected", _ => + ScenarioExpect.Throws(() => HealthEndpoint.Create().Build())) + .And("invalid check names are rejected", _ => + ScenarioExpect.Throws(() => HealthEndpoint.Create().WithCheck("", static _ => HealthEndpointCheckResult.HealthyCheck("database")))) + .And("null checks are rejected", _ => + ScenarioExpect.Throws(() => HealthEndpoint.Create().WithCheck("database", null!))) + .And("null contexts are rejected", _ => + ScenarioExpect.Throws(() => HealthEndpoint.Create().WithCheck("value", static value => HealthEndpointCheckResult.HealthyCheck(value)).Build().Evaluate(null!))) + .AssertPassed(); + + [Scenario("Health endpoint rejects null check results")] + [Fact] + public Task Health_Endpoint_Rejects_Null_Check_Results() + => Given("a health endpoint with an invalid check implementation", () => HealthEndpoint.Create() + .WithCheck("broken", static _ => null!) + .Build()) + .Then("the null check result is rejected", endpoint => + ScenarioExpect.Throws(() => endpoint.Evaluate(new(true, true, 0)))) + .AssertPassed(); + + private sealed record SystemHealth(bool DatabaseOnline, bool BrokerOnline, int QueueDepth); +}