Trailing slash middleware for the Infrastructure package (#53)
This commit is contained in:
@@ -5,7 +5,7 @@ The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) too
|
||||
| Role | Types |
|
||||
| --- | --- |
|
||||
| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
|
||||
| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` |
|
||||
| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` |
|
||||
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
|
||||
| Link previews | `SocialCard`, its `Image`, and the `Tag` meta tags it derives |
|
||||
| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, and the open `Name` and `Kind` vocabularies |
|
||||
@@ -30,7 +30,7 @@ Sources/
|
||||
│ ├── Enumerations/ AssetExtension
|
||||
│ ├── Extensions/ addController, plus the default header names and values
|
||||
│ ├── Methods/ FingerprintAssets
|
||||
│ ├── Middlewares/ the six HTTP middlewares
|
||||
│ ├── Middlewares/ the seven HTTP middlewares
|
||||
│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController
|
||||
│ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse
|
||||
│ └── Types/ Analytics, SocialCard, StructuredData (the latter two nesting their own types in SocialCard/ and StructuredData/)
|
||||
|
||||
+114
@@ -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 = "?"
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite(
|
||||
"TrailingSlashRedirectMiddleware middleware",
|
||||
.tags(.middleware)
|
||||
)
|
||||
struct TrailingSlashRedirectMiddlewareTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `redirects a path carrying a trailing slash`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
#expect(response.headers[.location] == "/hello")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `preserves the query of the redirected request`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello/?utm_source=test&utm_medium=email",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.location] == "/hello?utm_source=test&utm_medium=email")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `collapses a path of nothing but slashes onto the root`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "//",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
#expect(response.headers[.location] == "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `strips every trailing slash at once`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello///",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.location] == "/hello")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes the canonical path through`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.location] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `leaves the root path alone`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.location] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `redirects a head request as it does a get`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello/",
|
||||
method: .head
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
#expect(response.headers[.location] == "/hello")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes a post through so its body survives`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello/",
|
||||
method: .post
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.location] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension TrailingSlashRedirectMiddlewareTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds an application whose router applies the trailing-slash middleware ahead of a `/hello`
|
||||
/// route answering both `GET` and `POST`, and a root route standing in for the landing page.
|
||||
func app() -> some ApplicationProtocol {
|
||||
let router = Router()
|
||||
|
||||
router.addMiddleware {
|
||||
TrailingSlashRedirectMiddleware()
|
||||
}
|
||||
|
||||
router.get("hello") { _, _ in
|
||||
"Hello!"
|
||||
}
|
||||
|
||||
router.post("hello") { _, _ in
|
||||
"Posted!"
|
||||
}
|
||||
|
||||
router.get("/") { _, _ in
|
||||
"Root!"
|
||||
}
|
||||
|
||||
return Application(router: router)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user