Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:

# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
Expand All @@ -59,7 +59,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4

# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
Expand All @@ -72,6 +72,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
7 changes: 7 additions & 0 deletions .github/workflows/super-linter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ jobs:
VALIDATE_DOTNET_SLN_FORMAT_ANALYZERS: false
VALIDATE_DOTNET_SLN_FORMAT_STYLE: false
VALIDATE_DOTNET_SLN_FORMAT_WHITESPACE: false
# Stylelint's `standard` config rejects the camelCase CSS custom properties and the
# BEM style class names of the packaged Health Checks UI dashboard and Scalar, which the
# custom theme stylesheet in src/DotNet.ServiceName.Api/wwwroot/css has to override.
VALIDATE_CSS: false
# Prettier style is not enforced for any language in this repository, see the note above.
VALIDATE_CSS_PRETTIER: false
VALIDATE_JAVASCRIPT_PRETTIER: false
# Prettier style is not enforced; plain JSON/YAML/HTML syntax validation stays enabled.
VALIDATE_HTML_PRETTIER: false
VALIDATE_JSON_PRETTIER: false
Expand Down
30 changes: 15 additions & 15 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,24 @@
<ItemGroup>
<PackageVersion Include="Asp.Versioning.Mvc" Version="10.2.1" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="coverlet.collector" Version="10.1.0" />
<PackageVersion Include="DotNetDiag.HealthChecks.UI" Version="10.0.14" />
<PackageVersion Include="DotNetDiag.HealthChecks.UI.Client" Version="10.0.14" />
<PackageVersion Include="DotNetDiag.HealthChecks.UI.InMemory.Storage" Version="10.0.14" />
<PackageVersion Include="Facet" Version="6.6.8" />
<PackageVersion Include="Facet.Dashboard" Version="6.6.8" />
<PackageVersion Include="Facet.Extensions" Version="6.6.8" />
<PackageVersion Include="Facet" Version="6.6.12" />
<PackageVersion Include="Facet.Dashboard" Version="6.6.12" />
<PackageVersion Include="Facet.Extensions" Version="6.6.12" />
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.11" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.12" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageVersion Include="NSubstitute" Version="6.2.0" />
<PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageVersion Include="NetEscapades.AspNetCore.SecurityHeaders" Version="1.3.1" />
Expand All @@ -33,7 +33,7 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.19.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.19.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.19.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.17.1" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.17.10" />
<PackageVersion Include="Serilog" Version="4.4.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.ClientInfo" Version="2.9.0" />
Expand Down
68 changes: 67 additions & 1 deletion Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Docker setup for local development.

```
src/
DotNet.ServiceName.Api/ # Web API host: controllers, middleware, Swagger/Scalar, auth
DotNet.ServiceName.Api/ # Web API host: controllers, Razor home page, middleware, Swagger/Scalar, auth
DotNet.ServiceName.Application/ # Business logic, DTOs/facets, service registrations
DotNet.ServiceName.Common/ # Shared configuration options and extension helpers
tests/
Expand Down Expand Up @@ -93,6 +93,72 @@ The service exposes the following health check endpoints:
| `/health/live` | Liveness endpoint |
| `/healthcheck-dashboard` | Health Checks UI dashboard (when enabled in configuration) |

### Dashboard customization

The dashboard is a packaged single page application, so it is themed through a custom style sheet
that is loaded after its own one. The path is configured relative to `wwwroot`:

```json
"HealthCheckOptions": {
"HeaderText": "Simple Service - Health Checks Status",
"CustomStylesheet": "css/healthcheck-dashboard.css"
}
```

[`wwwroot/css/healthcheck-dashboard.css`](src/DotNet.ServiceName.Api/wwwroot/css/healthcheck-dashboard.css)
overrides the CSS custom properties the dashboard declares (colors, fonts, surfaces) and follows the
system theme with a dark palette. Every text and background pair it introduces is checked for at
least WCAG AA contrast (4.5:1) in both palettes - keep it that way when changing a color. The logo comes from [`wwwroot/images/healthcheck-logo.svg`](src/DotNet.ServiceName.Api/wwwroot/images/healthcheck-logo.svg)
through the `--logoImageUrl` property - the default logo of the dashboard is a remote image, so
replacing it also removes the external request. Use a root relative `url('/images/...')` in the
style sheet, because it is served from `/ui/resources/css`. Clear `CustomStylesheet` to fall back to
the dashboard defaults; a configured file that is missing in `wwwroot` is logged as a warning and
also falls back to the defaults.

## Home page

The root of the service (`/`) is a Razor Page (`src/DotNet.ServiceName.Api/Pages/Index.cshtml`) whose
links are built from the configuration of the running environment, so a production deployment does
not advertise UIs it does not expose. Its content is configured in `HomePageOptions`:

```json
"HomePageOptions": {
"Enabled": true,
"Title": "Simple Service API",
"Description": "Template service with API Key authorization, health checks and OpenTelemetry",
"ShowEnvironment": true,
"ShowDocumentation": true,
"ShowHealthChecks": true,
"Links": []
}
```

| Setting | Effect |
|---|---|
| `Enabled` | Serve the page on `/`. When `false`, Razor Pages are not registered at all and `/` returns `404` |
| `Title`, `Description` | Heading and lead text of the page |
| `ShowEnvironment` | Show the name of the current environment as a badge |
| `ShowDocumentation` | Show Swagger, Scalar and Facet Dashboard links - but only when `SwaggerEnabled` is `true` for the environment. When they are expected and turned off, the page says that the documentation is not available |
| `ShowHealthChecks` | Show the health status link, plus the dashboard link when the Health Checks UI is enabled |
| `Links` | Extra links (title, description, URL, icon, enabled, open in new tab) for anything the configuration above does not cover |
| `DefaultTheme` | Theme the page starts with: `System` (default), `Light` or `Dark` |

The page is a slim top bar (service name, environment badge, theme switch) above the link cards -
the name is the only heading of the document, so the page stays a single h1.

The theme switch has three positions (`System`, `Light`, `Dark`): the choice is stored in the browser
and applied by [`wwwroot/js/theme.js`](src/DotNet.ServiceName.Api/wwwroot/js/theme.js) before the
first paint, so the page never flashes in the wrong theme. The switch is a radio group, so it works
with the keyboard and screen readers, and while `System` is selected the page follows the operating
system when its theme changes. `DefaultTheme` only sets where the switch starts.

Turn the whole page off with `Enabled`, and tune the content per environment - the shipped
[`appsettings.Production.json`](src/DotNet.ServiceName.Api/appsettings.Production.json) is the sample
of a production deployment: `SwaggerEnabled: false` (so no Swagger, Scalar or Facet links), no
environment badge and a link to an internal runbook. Styles live in
[`wwwroot/css/home.css`](src/DotNet.ServiceName.Api/wwwroot/css/home.css) and the icons in
[`wwwroot/icons`](src/DotNet.ServiceName.Api/wwwroot/icons).

## Security headers

Non-development environments get a set of security headers applied by
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ public static IApplicationBuilder ConfigureSwagger(this IApplicationBuilder app,
// specifying the Swagger JSON endpoint.
app.UseSwaggerUI(options =>
{

// build a swagger endpoint for each discovered API version
foreach (var description in apiDescriptions)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Scalar.AspNetCore;

namespace DotNet.ServiceName.Api.Infrastructure.Extensions;
Expand Down Expand Up @@ -45,12 +48,32 @@ public static void AddHealthcheckEndpoints(this IEndpointRouteBuilder endpoints,
{
if (healthCheckConfig is { HealthCheckUiEnabled: true })
{
var environment = endpoints.ServiceProvider.GetRequiredService<IWebHostEnvironment>();
var logger = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger(
typeof(EndpointRouteBuilderExtensions));

// add Health Check UI
endpoints.MapHealthChecksUI(config =>
{
// TODO: add here custom styles and logic for Health Check dashboard if needed
// config.AddCustomStylesheet("wwwroot/styles/healthcheck-style.css");
config.UIPath = "/healthcheck-dashboard";
config.PageTitle = healthCheckConfig.HeaderText;

// the dashboard is a packaged single page application - it only accepts a custom
// stylesheet as a physical file, so resolve the configured wwwroot relative path
if (!string.IsNullOrWhiteSpace(healthCheckConfig.CustomStylesheet))
{
var stylesheet = environment.WebRootFileProvider.GetFileInfo(healthCheckConfig.CustomStylesheet);
if (stylesheet.Exists && !string.IsNullOrEmpty(stylesheet.PhysicalPath))
{
config.AddCustomStylesheet(stylesheet.PhysicalPath);
}
else
{
logger.LogWarning(
"Custom health check dashboard stylesheet '{Stylesheet}' was not found in wwwroot - dashboard defaults are used",
healthCheckConfig.CustomStylesheet);
}
}
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ private static IServiceCollection ConfigureHttpServices(this IServiceCollection
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});

// add the Razor Pages of the service - currently the home page only
if (configuration.GetHomePageConfiguration() is { Enabled: true })
{
services.AddRazorPages();
}

// add API versions
services.ConfigureApiVersions();

Expand Down
56 changes: 56 additions & 0 deletions src/DotNet.ServiceName.Api/Infrastructure/Models/HomePageModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using DotNet.ServiceName.Common.Configuration;

namespace DotNet.ServiceName.Api.Infrastructure.Models;

/// <summary>
/// Content of the home page, built per environment from the configuration.
/// </summary>
public sealed class HomePageModel
{
/// <summary>
/// Title (heading) of the page.
/// </summary>
public required string Title { get; init; }

/// <summary>
/// Short description shown under the title.
/// </summary>
public string? Description { get; init; }

/// <summary>
/// Name of the current environment, shown when <see cref="ShowEnvironment"/> is set.
/// </summary>
public string? Environment { get; init; }

/// <summary>
/// Show the environment badge.
/// </summary>
public bool ShowEnvironment { get; init; }

/// <summary>
/// Links available in the current environment.
/// </summary>
public required IReadOnlyList<HomePageLink> Links { get; init; }

/// <summary>
/// The documentation UIs are expected but turned off in this environment - the page says so
/// instead of silently showing fewer links.
/// </summary>
public bool DocumentationDisabled { get; init; }

/// <summary>
/// Theme the page starts with, before the visitor picks one.
/// </summary>
public HomePageTheme DefaultTheme { get; init; }
}

/// <summary>
/// Single link (button) on the home page.
/// </summary>
/// <param name="Title">Text of the link.</param>
/// <param name="Url">Target of the link.</param>
/// <param name="Description">Optional text shown under the title.</param>
/// <param name="Icon">Optional image shown in the link.</param>
/// <param name="OpenInNewTab">Open the link in a new browser tab.</param>
public sealed record HomePageLink(string Title, string Url, string? Description = null, string? Icon = null,
bool OpenInNewTab = false);
Loading
Loading