Skip to content

Commit 944509b

Browse files
authored
Merge pull request #1061 from DuendeSoftware/mb/middleware
Migrate custom middleware implementations to dedicated classes
2 parents 1ce6e1c + 09f80b1 commit 944509b

5 files changed

Lines changed: 96 additions & 61 deletions

File tree

server/src/Docs.Web/ContentNegotiationMiddleware.cs renamed to server/src/Docs.Web/Middleware/MarkdownContentNegotationMiddleware.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
namespace Docs.Web;
1+
namespace Docs.Web.Middleware;
22

33
/// <summary>
44
/// Middleware that serves .md files when the client sends Accept: text/markdown.
55
/// </summary>
6-
public class ContentNegotiationMiddleware(IWebHostEnvironment environment) : IMiddleware
6+
public class MarkdownContentNegotationMiddleware(IWebHostEnvironment environment) : IMiddleware
77
{
88
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
99
{
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace Docs.Web.Middleware;
2+
3+
/// <summary>
4+
/// Middleware that serves a custom 404.html page when the response status code is 404.
5+
/// </summary>
6+
public class NotFoundMiddleware(IWebHostEnvironment environment) : IMiddleware
7+
{
8+
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
9+
{
10+
await next(context);
11+
12+
if (context.Response.StatusCode == 404 && !context.Response.HasStarted)
13+
{
14+
var notFoundPath = Path.Combine(environment.WebRootPath, "404.html");
15+
16+
if (File.Exists(notFoundPath))
17+
{
18+
context.Response.ContentType = "text/html";
19+
await context.Response.SendFileAsync(notFoundPath);
20+
}
21+
}
22+
}
23+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace Docs.Web.Middleware;
2+
3+
/// <summary>
4+
/// Middleware that redirects old URLs to new destinations using a preloaded redirect map (301 permanent).
5+
/// </summary>
6+
public class RedirectMiddleware(IReadOnlyDictionary<string, string> redirectMap) : IMiddleware
7+
{
8+
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
9+
{
10+
var path = context.Request.Path.Value?.TrimEnd('/') ?? "";
11+
12+
if (redirectMap.TryGetValue(path, out var destination))
13+
{
14+
var queryString = context.Request.QueryString.HasValue ? context.Request.QueryString.Value : "";
15+
context.Response.StatusCode = 301;
16+
context.Response.Headers.Location = $"{destination}{queryString}";
17+
return;
18+
}
19+
20+
await next(context);
21+
}
22+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace Docs.Web.Middleware;
2+
3+
/// <summary>
4+
/// Middleware that redirects requests without a trailing slash to the same path with a trailing slash (301 permanent).
5+
/// Skips paths with file extensions and health-check endpoints.
6+
/// </summary>
7+
public class TrailingSlashMiddleware : IMiddleware
8+
{
9+
public Task InvokeAsync(HttpContext context, RequestDelegate next)
10+
{
11+
var path = context.Request.Path.Value;
12+
13+
if (!string.IsNullOrEmpty(path) &&
14+
!path.EndsWith("/") &&
15+
!Path.HasExtension(path) &&
16+
!path.StartsWith("/health") &&
17+
!path.StartsWith("/alive"))
18+
{
19+
var queryString = context.Request.QueryString.HasValue ? context.Request.QueryString.Value : "";
20+
context.Response.StatusCode = 301;
21+
context.Response.Headers.Location = $"{path}/{queryString}";
22+
return Task.CompletedTask;
23+
}
24+
25+
return next(context);
26+
}
27+
}

server/src/Docs.Web/Program.cs

Lines changed: 22 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Text.Json;
22
using Docs.Web;
3+
using Docs.Web.Middleware;
34

45
var builder = WebApplication.CreateBuilder(args);
56

@@ -8,16 +9,14 @@
89
// Add response compression
910
builder.Services.AddResponseCompression();
1011

11-
// Content negotiation middleware
12-
builder.Services.AddTransient<ContentNegotiationMiddleware>();
13-
14-
var app = builder.Build();
15-
16-
app.MapDefaultEndpoints();
12+
// Custom middlewares
13+
builder.Services.AddTransient<MarkdownContentNegotationMiddleware>();
14+
builder.Services.AddTransient<TrailingSlashMiddleware>();
15+
builder.Services.AddTransient<NotFoundMiddleware>();
1716

1817
// Load redirect map from Astro-generated redirects.json
1918
var redirectMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
20-
var redirectsPath = Path.Combine(app.Environment.WebRootPath, "redirects.json");
19+
var redirectsPath = Path.Combine(builder.Environment.WebRootPath, "redirects.json");
2120
if (File.Exists(redirectsPath))
2221
{
2322
var json = File.ReadAllText(redirectsPath);
@@ -29,55 +28,34 @@
2928
redirectMap[key] = value;
3029
}
3130
}
31+
}
32+
builder.Services.AddSingleton<IReadOnlyDictionary<string, string>>(redirectMap);
33+
builder.Services.AddTransient<RedirectMiddleware>();
34+
35+
var app = builder.Build();
36+
37+
if (redirectMap.Count > 0)
38+
{
3239
app.Logger.LogInformation("Loaded {Count} redirects from redirects.json", redirectMap.Count);
3340
}
3441
else
3542
{
36-
app.Logger.LogWarning("redirects.json not found at {Path}, no redirects will be applied", redirectsPath);
43+
app.Logger.LogWarning("No redirects loaded (redirects.json missing or empty at {Path})", redirectsPath);
3744
}
3845

39-
// Redirect middleware — match old URLs to new destinations (301 permanent)
40-
app.Use(async (context, next) =>
41-
{
42-
var path = context.Request.Path.Value?.TrimEnd('/') ?? "";
43-
44-
if (redirectMap.TryGetValue(path, out var destination))
45-
{
46-
var queryString = context.Request.QueryString.HasValue ? context.Request.QueryString.Value : "";
47-
context.Response.StatusCode = 301;
48-
context.Response.Headers.Location = $"{destination}{queryString}";
49-
return;
50-
}
46+
app.MapDefaultEndpoints();
5147

52-
await next();
53-
});
48+
// Redirect middleware — match old URLs to new destinations (301 permanent)
49+
app.UseMiddleware<RedirectMiddleware>();
5450

5551
// Enable response compression
5652
app.UseResponseCompression();
5753

58-
// Content negotiation: serve .md file when Accept: text/markdown
59-
app.UseMiddleware<ContentNegotiationMiddleware>();
54+
// Serve .md file when Accept: text/markdown
55+
app.UseMiddleware<MarkdownContentNegotationMiddleware>();
6056

6157
// Add trailing slash redirect middleware (replicate nginx behavior)
62-
app.Use(async (context, next) =>
63-
{
64-
var path = context.Request.Path.Value;
65-
66-
// If path doesn't end with slash and doesn't have a file extension, redirect with trailing slash
67-
if (!string.IsNullOrEmpty(path) &&
68-
!path.EndsWith("/") &&
69-
!Path.HasExtension(path) &&
70-
!path.StartsWith("/health") &&
71-
!path.StartsWith("/alive"))
72-
{
73-
var queryString = context.Request.QueryString.HasValue ? context.Request.QueryString.Value : "";
74-
context.Response.StatusCode = 301;
75-
context.Response.Headers.Location = $"{path}/{queryString}";
76-
return;
77-
}
78-
79-
await next();
80-
});
58+
app.UseMiddleware<TrailingSlashMiddleware>();
8159

8260
// Add version header if APPLICATION_VERSION is set
8361
var applicationVersion = Environment.GetEnvironmentVariable("APPLICATION_VERSION");
@@ -164,22 +142,7 @@
164142
});
165143

166144
// Handle 404 with custom page
167-
app.Use(async (context, next) =>
168-
{
169-
await next();
170-
171-
if (context.Response.StatusCode == 404 && !context.Response.HasStarted)
172-
{
173-
var webHostEnvironment = context.RequestServices.GetRequiredService<IWebHostEnvironment>();
174-
var notFoundPath = Path.Combine(webHostEnvironment.WebRootPath, "404.html");
175-
176-
if (File.Exists(notFoundPath))
177-
{
178-
context.Response.ContentType = "text/html";
179-
await context.Response.SendFileAsync(notFoundPath);
180-
}
181-
}
182-
});
145+
app.UseMiddleware<NotFoundMiddleware>();
183146

184147
// Fallback to index.html for directory requests
185148
app.MapFallback(async context =>

0 commit comments

Comments
 (0)