Skip to content

Commit

Permalink
Split out some logic and add health checks
Browse files Browse the repository at this point in the history
  • Loading branch information
evanlihou committed Jun 20, 2024
1 parent 2414944 commit e124d0f
Show file tree
Hide file tree
Showing 7 changed files with 289 additions and 186 deletions.
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ EXPOSE 8080
EXPOSE 8081

FROM mcr.microsoft.com/dotnet/nightly/sdk:9.0-preview AS build
ARG BUILD_CONFIGURATION=Release
ARG BUILD_CONFIGURATION=Debug
WORKDIR /src
COPY ["FiMAdminApi/FiMAdminApi.csproj", "FiMAdminApi/"]
RUN dotnet restore "FiMAdminApi/FiMAdminApi.csproj"
Expand All @@ -16,8 +16,8 @@ WORKDIR "/src/FiMAdminApi"
RUN dotnet build "FiMAdminApi.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "FiMAdminApi.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
ARG BUILD_CONFIGURATION=Debug
RUN dotnet publish "FiMAdminApi.csproj" -c $BUILD_CONFIGURATION -o /app/publish

FROM base AS final
WORKDIR /app
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;

namespace FiMAdminApi.Clients.Endpoints;
namespace FiMAdminApi.Endpoints;

public static class EventsCreateEndpoints
{
Expand Down
90 changes: 90 additions & 0 deletions FiMAdminApi/Endpoints/HealthzEndpoints.cs
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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
using Supabase.Gotrue.Interfaces;
using User = Supabase.Gotrue.User;

namespace FiMAdminApi.Clients.Endpoints;
namespace FiMAdminApi.Endpoints;

public static class UsersEndpoints
{
Expand Down
1 change: 1 addition & 0 deletions FiMAdminApi/FiMAdminApi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0-preview.5.24306.11" />
<PackageReference Include="Supabase" Version="1.0.1" />
Expand Down
182 changes: 182 additions & 0 deletions FiMAdminApi/Infrastructure/ApiStartupExtensions.cs
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>()
}
};
}
}
Loading

0 comments on commit e124d0f

Please sign in to comment.