-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Split out some logic and add health checks
- Loading branch information
Showing
7 changed files
with
289 additions
and
186 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
using Microsoft.AspNetCore.Mvc; | ||
using Microsoft.AspNetCore.OutputCaching; | ||
using Microsoft.Extensions.Diagnostics.HealthChecks; | ||
|
||
namespace FiMAdminApi.Endpoints; | ||
|
||
public static class HealthEndpoints | ||
{ | ||
public static WebApplication RegisterHealthEndpoints(this WebApplication app) | ||
{ | ||
var usersGroup = app.MapGroup("/healthz") | ||
.WithTags("Health Checks"); | ||
|
||
usersGroup.MapGet("", async ([FromServices] HealthCheckService hc, [FromServices] ILoggerFactory logger) => | ||
{ | ||
var report = await hc.CheckHealthAsync(); | ||
logger.CreateLogger(nameof(HealthEndpoints)).LogWarning("In the health check"); | ||
return report.Status switch | ||
{ | ||
HealthStatus.Healthy => TypedResults.Ok(report), | ||
HealthStatus.Degraded => Results.Ok(report), | ||
HealthStatus.Unhealthy => Results.Json(report, statusCode: StatusCodes.Status503ServiceUnavailable), | ||
_ => throw new ArgumentOutOfRangeException() | ||
}; | ||
}) | ||
.WithSummary("Get Service Status") | ||
.WithDescription("Note that this endpoint may return a cached response (up to 10 minutes). Check the `Age` header for the age of the data in seconds.") | ||
.Produces<ThinHealthReport>() | ||
.Produces<ThinHealthReport>(StatusCodes.Status503ServiceUnavailable).CacheOutput(pol => | ||
{ | ||
pol.AddPolicy<LaxCachingPolicy>(); | ||
pol.Expire(TimeSpan.FromMinutes(10)); | ||
}); | ||
|
||
return app; | ||
} | ||
|
||
private class ThinHealthReport(HealthReport report) | ||
{ | ||
public required HealthStatus Status { get; set; } = report.Status; | ||
public required string TotalDuration { get; set; } = report.TotalDuration.ToString(); | ||
|
||
public required Dictionary<string, Entry> Entries { get; set; } = | ||
report.Entries.Select(kvp => new KeyValuePair<string, Entry>(kvp.Key, new Entry(kvp.Value))).ToDictionary(); | ||
|
||
internal class Entry(HealthReportEntry entry) | ||
{ | ||
public IReadOnlyDictionary<string, object> Data { get; set; } = entry.Data; | ||
public string? Description { get; set; } = entry.Description; | ||
public string Duration { get; set; } = entry.Duration.ToString(); | ||
public HealthStatus Status { get; set; } = entry.Status; | ||
public IEnumerable<string>? Tags { get; set; } = entry.Tags; | ||
} | ||
} | ||
|
||
public class LaxCachingPolicy : IOutputCachePolicy | ||
{ | ||
public LaxCachingPolicy() | ||
{ | ||
} | ||
|
||
/// <inheritdoc /> | ||
ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) | ||
{ | ||
context.EnableOutputCaching = true; | ||
context.AllowCacheLookup = true; | ||
context.AllowCacheStorage = true; | ||
context.AllowLocking = true; | ||
|
||
// Vary by any query by default | ||
context.CacheVaryByRules.QueryKeys = "*"; | ||
|
||
return ValueTask.CompletedTask; | ||
} | ||
|
||
/// <inheritdoc /> | ||
ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) | ||
{ | ||
return ValueTask.CompletedTask; | ||
} | ||
|
||
/// <inheritdoc /> | ||
ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) | ||
{ | ||
return ValueTask.CompletedTask; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,182 @@ | ||
using System.ComponentModel; | ||
using System.Net.Mime; | ||
using System.Reflection; | ||
using Asp.Versioning; | ||
using Microsoft.AspNetCore.Authentication; | ||
using Microsoft.AspNetCore.OpenApi; | ||
using Microsoft.OpenApi.Models; | ||
using Swashbuckle.AspNetCore.SwaggerGen; | ||
|
||
namespace FiMAdminApi.Infrastructure; | ||
|
||
public static class ApiStartupExtensions | ||
{ | ||
public static IServiceCollection AddApiConfiguration(this IServiceCollection services) | ||
{ | ||
services.AddApiVersioning(opt => | ||
{ | ||
opt.DefaultApiVersion = new ApiVersion(1.0); | ||
opt.ReportApiVersions = true; | ||
opt.ApiVersionReader = new UrlSegmentApiVersionReader(); | ||
}).AddMvc().AddApiExplorer(opt => | ||
{ | ||
opt.GroupNameFormat = "'v'VVV"; | ||
opt.SubstituteApiVersionInUrl = true; | ||
}); | ||
services.AddProblemDetails(); | ||
services.AddEndpointsApiExplorer(); | ||
|
||
// TODO: Remove when OpenAPI is working | ||
services.AddSwaggerGen(opt => | ||
{ | ||
var scheme = new OpenApiSecurityScheme() | ||
{ | ||
Type = SecuritySchemeType.Http, | ||
Scheme = "bearer", | ||
In = ParameterLocation.Header, | ||
BearerFormat = "JSON Web Token" | ||
}; | ||
opt.AddSecurityDefinition("Bearer", scheme); | ||
opt.AddSecurityRequirement(new OpenApiSecurityRequirement() | ||
{ | ||
{ scheme, [] } | ||
}); | ||
opt.OperationFilter<AuthorizeCheckOperationFilter>(); | ||
}); | ||
|
||
services.AddOpenApi(opt => | ||
{ | ||
opt.UseTransformer((doc, _, _) => | ||
{ | ||
doc.Info = new OpenApiInfo | ||
{ | ||
Title = "FiM Admin API", | ||
Description = | ||
"A collection of endpoints that require more stringent authorization or business logic", | ||
Version = "v1" | ||
}; | ||
return Task.CompletedTask; | ||
}); | ||
opt.UseTransformer<BearerSecuritySchemeTransformer>(); | ||
opt.UseTransformer((doc, ctx, ct) => | ||
{ | ||
foreach (var tag in doc.Tags) | ||
{ | ||
var controllerType = Assembly.GetExecutingAssembly() | ||
.GetType($"FiMAdminApi.Controllers.{tag.Name}Controller", false); | ||
if (controllerType is null) continue; | ||
var description = controllerType.GetCustomAttribute<DescriptionAttribute>()?.Description; | ||
tag.Description = description; | ||
} | ||
return Task.CompletedTask; | ||
}); | ||
}); | ||
|
||
return services; | ||
} | ||
|
||
public static IEndpointRouteBuilder UseApiConfiguration(this IEndpointRouteBuilder app) | ||
{ | ||
// Configure the HTTP request pipeline. | ||
app.MapOpenApi(); | ||
|
||
// TODO: Remove when OpenAPI is working | ||
app.MapSwagger(); | ||
|
||
return app; | ||
} | ||
|
||
public static IEndpointRouteBuilder UseApiDocumentation(this IEndpointRouteBuilder app) | ||
{ | ||
// Redirect from the root to API docs | ||
app.MapGet("/", ctx => | ||
{ | ||
ctx.Response.Redirect("/docs"); | ||
return Task.CompletedTask; | ||
}).ExcludeFromDescription(); | ||
|
||
// Serve API documentation | ||
// TODO: Update to `/openapi/v1.json` when OpenAPI is working | ||
app.MapGet("/docs", () => | ||
{ | ||
const string resp = """ | ||
<html> | ||
<head> | ||
<script src="https://unpkg.com/@stoplight/elements/web-components.min.js"></script> | ||
<link rel="stylesheet" href="https://unpkg.com/@stoplight/elements/styles.min.css"> | ||
</head> | ||
<body> | ||
<elements-api apiDescriptionUrl="/swagger/v1/swagger.json" router="hash" basePath="/docs"/> | ||
</body> | ||
</html> | ||
"""; | ||
return Results.Content(resp, MediaTypeNames.Text.Html); | ||
}).ExcludeFromDescription(); | ||
|
||
return app; | ||
} | ||
} | ||
|
||
internal sealed class BearerSecuritySchemeTransformer(IAuthenticationSchemeProvider authenticationSchemeProvider) : IOpenApiDocumentTransformer | ||
{ | ||
public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) | ||
{ | ||
var authenticationSchemes = await authenticationSchemeProvider.GetAllSchemesAsync(); | ||
if (authenticationSchemes.Any(authScheme => authScheme.Name == "Bearer")) | ||
{ | ||
// Add the security scheme at the document level | ||
var requirements = new Dictionary<string, OpenApiSecurityScheme> | ||
{ | ||
["Bearer"] = new OpenApiSecurityScheme | ||
{ | ||
Type = SecuritySchemeType.Http, | ||
Scheme = "bearer", // "bearer" refers to the header name here | ||
In = ParameterLocation.Header, | ||
BearerFormat = "Json Web Token" | ||
} | ||
}; | ||
document.Components ??= new OpenApiComponents(); | ||
document.Components.SecuritySchemes = requirements; | ||
|
||
// Apply it as a requirement for all operations | ||
foreach (var operation in document.Paths.Values.SelectMany(path => path.Operations)) | ||
{ | ||
operation.Value.Security.Add(new OpenApiSecurityRequirement | ||
{ | ||
[new OpenApiSecurityScheme { Reference = new OpenApiReference { Id = "Bearer", Type = ReferenceType.SecurityScheme } }] = Array.Empty<string>() | ||
}); | ||
} | ||
} | ||
} | ||
} | ||
|
||
// TODO: Remove when OpenAPI is working | ||
public class AuthorizeCheckOperationFilter : IOperationFilter | ||
{ | ||
public void Apply(OpenApiOperation operation, OperationFilterContext context) | ||
{ | ||
operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" }); | ||
// operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" }); | ||
|
||
var jwtBearerScheme = new OpenApiSecurityScheme | ||
{ | ||
Reference = new OpenApiReference | ||
{ | ||
Type = ReferenceType.SecurityScheme, | ||
Id = "Bearer" | ||
} | ||
}; | ||
|
||
operation.Security = new List<OpenApiSecurityRequirement> | ||
{ | ||
new OpenApiSecurityRequirement | ||
{ | ||
[jwtBearerScheme] = Array.Empty<string>() | ||
} | ||
}; | ||
} | ||
} |
Oops, something went wrong.