diff --git a/.assets/img/Liveness.png b/.assets/img/Liveness.png new file mode 100644 index 00000000..151dc4bb Binary files /dev/null and b/.assets/img/Liveness.png differ diff --git a/.assets/img/LivenessMVC.png b/.assets/img/LivenessMVC.png new file mode 100644 index 00000000..d419e645 Binary files /dev/null and b/.assets/img/LivenessMVC.png differ diff --git a/.assets/img/Readiness.png b/.assets/img/Readiness.png new file mode 100644 index 00000000..f40f6faf Binary files /dev/null and b/.assets/img/Readiness.png differ diff --git a/.assets/img/ReadinessMVC.png b/.assets/img/ReadinessMVC.png new file mode 100644 index 00000000..6c80adb9 Binary files /dev/null and b/.assets/img/ReadinessMVC.png differ diff --git a/README.md b/README.md index 42546df5..f78f3c56 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ After this, to configure the HTTP client, `init` secrets in [`./src/Dotnet5.Grap ```bash dotnet user-secrets init -dotnet user-secrets set "HttpClient:Store" "http://localhost:5000/graphql" +dotnet user-secrets set "HttpClient:Store" "http://localhost:5000" ``` ##### AppSettings @@ -57,7 +57,7 @@ WebMCV ```json5 { "HttpClient": { - "Store": "http://localhost:5000/graphql" + "Store": "http://localhost:5000" } } ``` @@ -84,7 +84,7 @@ WebMCV ```json5 { "HttpClient": { - "Store": "http://webapi:5000/graphql" + "Store": "http://webapi:5000" } } ``` @@ -343,8 +343,36 @@ networks: graphqlstore: driver: bridge ``` +### Health checks -## GraphQL Playground +Based on cloud-native concepts, **Readiness** and **Liveness** integrity verification strategies were implemented. + +Web API + +`http://localhost:5000/health/ready` + +![Readiness](./.assets/img/Readiness.png) + + +`http://localhost:5000/health/live` + +![Liveness](./.assets/img/Liveness.png) + +--- + +Web MVC + +`http://localhost:7000/health/ready` + +![Readiness](./.assets/img/ReadinessMVC.png) + +`http://localhost:7000/health/live` + +![Liveness](./.assets/img/LivenessMVC.png) + +--- + +### GraphQL Playground By default **Playground** respond at `http://localhost:5000/ui/playground` but is possible configure the host and many others details in [`../...WebAPI/GraphQL/DependencyInjection/Configure.cs`](./src/Dotnet5.GraphQL3.Store.WebAPI/GraphQL/DependencyInjection/Configure.cs) @@ -388,7 +416,6 @@ fragment comparisonFields on product { description } ``` - RESULT ```json5 @@ -410,8 +437,8 @@ RESULT } ``` ___ -#### Query named's and Variables +#### Query named's and Variables QUERY diff --git a/src/Dotnet5.GraphQL3.Store.WebAPI/Dotnet5.GraphQL3.Store.WebAPI.csproj b/src/Dotnet5.GraphQL3.Store.WebAPI/Dotnet5.GraphQL3.Store.WebAPI.csproj index 20b23096..c9137533 100644 --- a/src/Dotnet5.GraphQL3.Store.WebAPI/Dotnet5.GraphQL3.Store.WebAPI.csproj +++ b/src/Dotnet5.GraphQL3.Store.WebAPI/Dotnet5.GraphQL3.Store.WebAPI.csproj @@ -5,6 +5,7 @@ + diff --git a/src/Dotnet5.GraphQL3.Store.WebAPI/Extensions/EndpointRouteBuilders/EndpointRouteBuilderExtensions.cs b/src/Dotnet5.GraphQL3.Store.WebAPI/Extensions/EndpointRouteBuilders/EndpointRouteBuilderExtensions.cs new file mode 100644 index 00000000..8222cec5 --- /dev/null +++ b/src/Dotnet5.GraphQL3.Store.WebAPI/Extensions/EndpointRouteBuilders/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,70 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Newtonsoft.Json; + +namespace Dotnet5.GraphQL3.Store.WebAPI.Extensions.EndpointRouteBuilders +{ + public static class EndpointRouteBuilderExtensions + { + public static void MapLivenessHealthChecks(this IEndpointRouteBuilder endpoints) + => endpoints.MapHealthChecks( + pattern: "/health/live", + options: new HealthCheckOptions + { + AllowCachingResponses = false, + ResponseWriter = WriteHealthCheckLiveResponseAsync, + Predicate = registration => registration.Tags.Any() is false + }); + + public static void MapReadinessHealthChecks(this IEndpointRouteBuilder endpoints) + => endpoints.MapHealthChecks( + pattern: "/health/ready", + options: new HealthCheckOptions + { + AllowCachingResponses = false, + ResponseWriter = WriteHealthCheckReadyResponseAsync, + Predicate = registration => registration.Tags.Contains("ready"), + ResultStatusCodes = + { + [HealthStatus.Healthy] = StatusCodes.Status200OK, + [HealthStatus.Degraded] = StatusCodes.Status500InternalServerError, + [HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable + } + }); + + private static Task WriteHealthCheckReadyResponseAsync(HttpContext httpContext, HealthReport healthReport) + { + httpContext.Response.ContentType = "application/json"; + + return httpContext.Response.WriteAsync( + JsonConvert.SerializeObject(new + { + OverallStatus = healthReport.Status.ToString(), + TotalCheckDuration = healthReport.TotalDuration.TotalSeconds.ToString("00:00:00.000"), + DependencyHealthChecks = healthReport.Entries.Select( + dependency => new + { + Name = dependency.Key, + Status = dependency.Value.Status.ToString(), + Duration = dependency.Value.Duration.TotalSeconds.ToString("00:00:00.000") + }) + })); + } + + private static Task WriteHealthCheckLiveResponseAsync(HttpContext httpContext, HealthReport healthReport) + { + httpContext.Response.ContentType = "application/json"; + return httpContext.Response.WriteAsync( + JsonConvert.SerializeObject(new + { + OverallStatus = healthReport.Status.ToString(), + TotalCheckDuration = healthReport.TotalDuration.TotalSeconds.ToString("00:00:00.000") + })); + } + } +} \ No newline at end of file diff --git a/src/Dotnet5.GraphQL3.Store.WebAPI/Startup.cs b/src/Dotnet5.GraphQL3.Store.WebAPI/Startup.cs index 5a4b1011..4a89e429 100644 --- a/src/Dotnet5.GraphQL3.Store.WebAPI/Startup.cs +++ b/src/Dotnet5.GraphQL3.Store.WebAPI/Startup.cs @@ -3,6 +3,7 @@ using Dotnet5.GraphQL3.Repositories.Abstractions.Extensions.DependencyInjection; using Dotnet5.GraphQL3.Services.Abstractions.Extensions.DependencyInjection; using Dotnet5.GraphQL3.Store.Repositories.Extensions.DependencyInjection; +using Dotnet5.GraphQL3.Store.WebAPI.Extensions.EndpointRouteBuilders; using Dotnet5.GraphQL3.Store.WebAPI.GraphQL; using Dotnet5.GraphQL3.Store.WebAPI.GraphQL.Extensions.DependencyInjection; using Microsoft.AspNetCore.Builder; @@ -11,6 +12,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; namespace Dotnet5.GraphQL3.Store.WebAPI @@ -32,8 +34,12 @@ public void Configure(IApplicationBuilder app, DbContext dbContext) app.UseDeveloperExceptionPage(); app.UseRouting() - .UseEndpoints(endpoints - => endpoints.MapControllers()); + .UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapLivenessHealthChecks(); + endpoints.MapReadinessHealthChecks(); + }); app.UseApplicationGraphQL(); @@ -62,6 +68,12 @@ public void ConfigureServices(IServiceCollection services) services.Configure(options => options.AllowSynchronousIO = true); + + services.AddHealthChecks() + .AddSqlServer( + connectionString: _configuration.GetConnectionString("DefaultConnection"), + failureStatus: HealthStatus.Unhealthy, + tags: new[] {"ready"}); } } } \ No newline at end of file diff --git a/src/Dotnet5.GraphQL3.Store.WebMVC/Dotnet5.GraphQL3.Store.WebMVC.csproj b/src/Dotnet5.GraphQL3.Store.WebMVC/Dotnet5.GraphQL3.Store.WebMVC.csproj index 41b7f59d..cf0497d2 100644 --- a/src/Dotnet5.GraphQL3.Store.WebMVC/Dotnet5.GraphQL3.Store.WebMVC.csproj +++ b/src/Dotnet5.GraphQL3.Store.WebMVC/Dotnet5.GraphQL3.Store.WebMVC.csproj @@ -5,6 +5,7 @@ + diff --git a/src/Dotnet5.GraphQL3.Store.WebMVC/Extensions/EndpointRouteBuilders/EndpointRouteBuilderExtensions.cs b/src/Dotnet5.GraphQL3.Store.WebMVC/Extensions/EndpointRouteBuilders/EndpointRouteBuilderExtensions.cs new file mode 100644 index 00000000..2938225a --- /dev/null +++ b/src/Dotnet5.GraphQL3.Store.WebMVC/Extensions/EndpointRouteBuilders/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,70 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Newtonsoft.Json; + +namespace Dotnet5.GraphQL3.Store.WebMVC.Extensions.EndpointRouteBuilders +{ + public static class EndpointRouteBuilderExtensions + { + public static void MapLivenessHealthChecks(this IEndpointRouteBuilder endpoints) + => endpoints.MapHealthChecks( + pattern: "/health/live", + options: new HealthCheckOptions + { + AllowCachingResponses = false, + ResponseWriter = WriteHealthCheckLiveResponseAsync, + Predicate = registration => registration.Tags.Any() is false + }); + + public static void MapReadinessHealthChecks(this IEndpointRouteBuilder endpoints) + => endpoints.MapHealthChecks( + pattern: "/health/ready", + options: new HealthCheckOptions + { + AllowCachingResponses = false, + ResponseWriter = WriteHealthCheckReadyResponseAsync, + Predicate = registration => registration.Tags.Contains("ready"), + ResultStatusCodes = + { + [HealthStatus.Healthy] = StatusCodes.Status200OK, + [HealthStatus.Degraded] = StatusCodes.Status500InternalServerError, + [HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable + } + }); + + private static Task WriteHealthCheckReadyResponseAsync(HttpContext httpContext, HealthReport healthReport) + { + httpContext.Response.ContentType = "application/json"; + + return httpContext.Response.WriteAsync( + JsonConvert.SerializeObject(new + { + OverallStatus = healthReport.Status.ToString(), + TotalCheckDuration = healthReport.TotalDuration.TotalSeconds.ToString("00:00:00.000"), + DependencyHealthChecks = healthReport.Entries.Select( + dependency => new + { + Name = dependency.Key, + Status = dependency.Value.Status.ToString(), + Duration = dependency.Value.Duration.TotalSeconds.ToString("00:00:00.000") + }) + })); + } + + private static Task WriteHealthCheckLiveResponseAsync(HttpContext httpContext, HealthReport healthReport) + { + httpContext.Response.ContentType = "application/json"; + return httpContext.Response.WriteAsync( + JsonConvert.SerializeObject(new + { + OverallStatus = healthReport.Status.ToString(), + TotalCheckDuration = healthReport.TotalDuration.TotalSeconds.ToString("00:00:00.000") + })); + } + } +} \ No newline at end of file diff --git a/src/Dotnet5.GraphQL3.Store.WebMVC/Startup.cs b/src/Dotnet5.GraphQL3.Store.WebMVC/Startup.cs index aa7f6b0d..31e59695 100644 --- a/src/Dotnet5.GraphQL3.Store.WebMVC/Startup.cs +++ b/src/Dotnet5.GraphQL3.Store.WebMVC/Startup.cs @@ -1,27 +1,31 @@ using System; using Dotnet5.GraphQL3.Store.WebMVC.Clients; +using Dotnet5.GraphQL3.Store.WebMVC.Extensions.EndpointRouteBuilders; using GraphQL.Client.Http; using GraphQL.Client.Serializer.SystemTextJson; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; namespace Dotnet5.GraphQL3.Store.WebMVC { public class Startup { - public Startup(IConfiguration configuration) + private readonly IConfiguration _configuration; + private readonly IWebHostEnvironment _env; + + public Startup(IConfiguration configuration, IWebHostEnvironment env) { - Configuration = configuration; + _env = env; + _configuration = configuration; } - public IConfiguration Configuration { get; } - - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + public void Configure(IApplicationBuilder app) { - if (env.IsDevelopment()) + if (_env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } @@ -38,6 +42,9 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { endpoints.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}"); + + endpoints.MapReadinessHealthChecks(); + endpoints.MapLivenessHealthChecks(); }); } @@ -45,9 +52,9 @@ public void ConfigureServices(IServiceCollection services) { services.AddControllersWithViews(); - services.AddSingleton(provider + services.AddSingleton(_ => new GraphQLHttpClient( - endPoint: new Uri(Configuration["HttpClient:Store"]), + endPoint: new Uri($"{_configuration["HttpClient:Store"]}/graphql"), serializer: new SystemTextJsonSerializer(options => { options.PropertyNameCaseInsensitive = true; @@ -55,6 +62,13 @@ public void ConfigureServices(IServiceCollection services) }))); services.AddSingleton(); + + services.AddHealthChecks() + .AddUrlGroup( + uri: new Uri($"{_configuration["HttpClient:Store"]}/health/ready"), + name: "Store Web API", + failureStatus: HealthStatus.Unhealthy, + tags: new[] {"ready"}); } } } \ No newline at end of file diff --git a/src/Dotnet5.GraphQL3.Store.WebMVC/appsettings.json b/src/Dotnet5.GraphQL3.Store.WebMVC/appsettings.json index 80343e9c..e79d54c5 100644 --- a/src/Dotnet5.GraphQL3.Store.WebMVC/appsettings.json +++ b/src/Dotnet5.GraphQL3.Store.WebMVC/appsettings.json @@ -1,6 +1,6 @@ { "HttpClient": { - "Store": "http://webapi:5000/graphql" + "Store": "http://webapi:5000" }, "Logging": { "LogLevel": {