Trailing slash middleware for the Infrastructure package (#53)

This commit is contained in:
2026-08-24 17:33:52 +02:00
parent 1520c44b16
commit f6a77305ac
7 changed files with 293 additions and 10 deletions
@@ -0,0 +1,114 @@
import HTTPTypes
import Hummingbird
/// Answers a request whose path carries a trailing slash with `301 Moved Permanently` to the same path without one.
///
/// The router matches `/mr-rock` and `/mr-rock/` alike, and `FileMiddleware` serves `/robots.txt/` as readily as `/robots.txt`, so without this
/// every address on the site answers under at least two URLs. A search engine treats those as separate pages competing with each other, and any link
/// earned by one is not credited to the other. Redirecting collapses them onto the form the pages already name as canonical.
///
/// Two deliberate choices:
/// - **The `Location` is relative.** A relative target resolves against the request's own scheme and host, so the middleware needs no configured
/// origin and cannot be aimed elsewhere by a forged `Host` header.
/// - **Only `GET` and `HEAD` are redirected.** A client answering a `301` to a `POST` may repeat it as a `GET` and drop the body, so a form
/// submission is left to the route that already matches it.
///
/// - Note: `Context` is the request context the middleware is resolved against.
public struct TrailingSlashRedirectMiddleware<Context: RequestContext>: Sendable {
// MARK: Initializers
/// Creates a trailing-slash redirect middleware.
public init() {}
}
// MARK: - RouterMiddleware
extension TrailingSlashRedirectMiddleware: RouterMiddleware {
// MARK: Functions
/// Redirects a `GET` or `HEAD` whose path carries a trailing slash, and passes every other request down the chain.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the redirect, or the downstream response.
/// - Throws: any error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
let path = request.uri.path
let canonical = canonicalPath(of: path)
guard
request.method == .get || request.method == .head,
canonical != path
else {
return try await next(
request,
context
)
}
var response = Response(status: .movedPermanently)
response.headers[.location] = canonical + query(of: request)
return response
}
}
// MARK: - Helpers
private extension TrailingSlashRedirectMiddleware {
// MARK: Methods
/// The path with its trailing slashes removed, which is the form the pages name as their canonical URL.
///
/// A path of nothing but slashes collapses to the root, so `//` redirects to `/` while `/` itself is left alone.
/// - Parameter path: the requested path.
/// - Returns: the canonical form of the path.
func canonicalPath(
of path: String
) -> String {
var canonical = path
while canonical.hasSuffix(.pathSeparator), canonical != .pathSeparator {
canonical.removeLast()
}
return canonical
}
/// The query the redirect preserves, so a campaign-tagged link survives the canonicalization.
/// - Parameter request: the incoming request.
/// - Returns: the query prefixed with its delimiter, or an empty string when the request carries none.
func query(
of request: Request
) -> String {
guard
let query = request.uri.query,
!query.isEmpty
else {
return ""
}
return .querySeparator + query
}
}
// MARK: - String+Constants
private extension String {
/// The separator a canonical path never ends on, and the root path it collapses to.
static let pathSeparator = "/"
/// The delimiter placed between a redirect target's path and its query.
static let querySeparator = "?"
}