Skip to content

Commit 0b19c86

Browse files
committed
Dynamic Redirect
1 parent d18c833 commit 0b19c86

File tree

4 files changed

+200
-5
lines changed

4 files changed

+200
-5
lines changed

FlyingFox/Sources/HTTPHandler.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ public extension HTTPHandler where Self == RedirectHTTPHandler {
5656
static func redirect(to location: String) -> RedirectHTTPHandler {
5757
RedirectHTTPHandler(location: location)
5858
}
59+
60+
static func redirect(via base: String, serverPath: String? = nil) -> RedirectHTTPHandler {
61+
RedirectHTTPHandler(base: base, serverPath: serverPath)
62+
}
5963
}
6064

6165
public extension HTTPHandler where Self == ProxyHTTPHandler {

FlyingFox/Sources/Handlers/RedirectHTTPHandler.swift

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,101 @@ import Foundation
3333

3434
public struct RedirectHTTPHandler: HTTPHandler {
3535

36-
private let location: String
36+
private let destination: Destination
37+
private let statusCode: HTTPStatusCode
38+
private let serverPath: String?
3739

38-
public init(location: String) {
39-
self.location = location
40+
public init(base: String, statusCode: HTTPStatusCode = .movedPermanently, serverPath: String? = nil) {
41+
self.destination = .base(base, serverPath: serverPath)
42+
self.statusCode = statusCode
43+
self.serverPath = serverPath
44+
}
45+
46+
public init(location: String, statusCode: HTTPStatusCode = .movedPermanently) {
47+
self.destination = .location(location)
48+
self.statusCode = statusCode
49+
self.serverPath = nil
4050
}
4151

4252
public func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse {
43-
guard let url = URL(string: location) else {
53+
switch destination {
54+
case let .location(location):
55+
guard let url = URL(string: location) else {
56+
throw URLError(.badURL)
57+
}
58+
return try handleRedirect(to: url)
59+
case let .base(base, serverPath):
60+
let url = try makeRedirectLocation(for: request, via: base, serverPath: serverPath ?? "")
61+
return try handleRedirect(to: url)
62+
}
63+
}
64+
65+
private enum Destination {
66+
case location(String)
67+
case base(String, serverPath: String?)
68+
}
69+
70+
private func handleRedirect(to url: URL) throws -> HTTPResponse {
71+
guard Self.isRedirect(statusCode) else {
4472
throw URLError(.badURL)
4573
}
4674
return HTTPResponse(
47-
statusCode: .movedPermanently,
75+
statusCode: statusCode,
4876
headers: [.location: url.absoluteString]
4977
)
5078
}
79+
80+
private func makeRedirectLocation(for request: HTTPRequest, via base: String, serverPath: String) throws -> URL {
81+
guard let base = URL(string: base) else {
82+
throw URLError(.badURL)
83+
}
84+
85+
let compsA = serverPath
86+
.split(separator: "/", omittingEmptySubsequences: true)
87+
.joined(separator: "/")
88+
89+
let compsB = request.path
90+
.split(separator: "/", omittingEmptySubsequences: true)
91+
.joined(separator: "/")
92+
93+
guard !compsA.isEmpty else {
94+
return try base.appendingRequest(request)
95+
}
96+
97+
guard compsB.hasPrefix(compsA) else {
98+
throw URLError(.badURL)
99+
}
100+
101+
var request = request
102+
request.path = String(compsB.dropFirst(compsA.count))
103+
return try base.appendingRequest(request)
104+
}
105+
106+
static let redirectStatusCodes: Set<HTTPStatusCode> = [.movedPermanently, .found, .seeOther, .temporaryRedirect, .permanentRedirect]
107+
static func isRedirect(_ code: HTTPStatusCode) -> Bool {
108+
redirectStatusCodes.contains(code)
109+
}
110+
}
111+
112+
private extension URL {
113+
114+
func appendingRequest(_ request: HTTPRequest) throws -> URL {
115+
guard var comps = URLComponents(url: appendingPathComponent(request.path), resolvingAgainstBaseURL: false) else {
116+
throw URLError(.badURL)
117+
}
118+
119+
var items = comps.queryItems ?? []
120+
items.append(
121+
contentsOf: request.query.map { URLQueryItem(name: $0.name, value: $0.value) }
122+
)
123+
124+
if !items.isEmpty {
125+
comps.queryItems = items
126+
}
127+
128+
guard let url = comps.url else {
129+
throw URLError(.badURL)
130+
}
131+
return url
132+
}
51133
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//
2+
// RedirectHTTPHandlerTests.swift
3+
// FlyingFox
4+
//
5+
// Created by Simon Whitty on 13/09/2025.
6+
// Copyright © 2025 Simon Whitty. All rights reserved.
7+
//
8+
// Distributed under the permissive MIT license
9+
// Get the latest version from here:
10+
//
11+
// https://github.com/swhitty/FlyingFox
12+
//
13+
// Permission is hereby granted, free of charge, to any person obtaining a copy
14+
// of this software and associated documentation files (the "Software"), to deal
15+
// in the Software without restriction, including without limitation the rights
16+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17+
// copies of the Software, and to permit persons to whom the Software is
18+
// furnished to do so, subject to the following conditions:
19+
//
20+
// The above copyright notice and this permission notice shall be included in all
21+
// copies or substantial portions of the Software.
22+
//
23+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29+
// SOFTWARE.
30+
//
31+
32+
@testable import FlyingFox
33+
import Foundation
34+
import Testing
35+
36+
struct RedirectHTTPHandlerTests {
37+
38+
@Test
39+
func location_is_not_replaced() async throws {
40+
let handler = RedirectHTTPHandler(location: "http://www.fish.com")
41+
42+
var response = try await handler.handleRequest(.make(path: "/"))
43+
#expect(response.statusCode == .movedPermanently)
44+
#expect(response.headers[.location] == "http://www.fish.com")
45+
46+
response = try await handler.handleRequest(.make(path: "/chips", query: [.init(name: "fish", value: "true")]))
47+
#expect(response.headers[.location] == "http://www.fish.com")
48+
}
49+
50+
@Test
51+
func location_statuscode() async throws {
52+
let handler = RedirectHTTPHandler(location: "http://www.fish.com", statusCode: .temporaryRedirect)
53+
54+
let response = try await handler.handleRequest(.make(path: "/"))
55+
#expect(response.statusCode == .temporaryRedirect)
56+
}
57+
58+
@Test
59+
func base_appends_request() async throws {
60+
let handler: any HTTPHandler = .redirect(via: "http://fish.com")
61+
62+
var response = try await handler.handleRequest(.make(path: "/chips/shrimp"))
63+
#expect(response.statusCode == .movedPermanently)
64+
#expect(response.headers[.location] == "http://fish.com/chips/shrimp")
65+
66+
response = try await handler.handleRequest(.make(path: "/chips/shrimp", query: [.init(name: "fish", value: "true")]))
67+
#expect(response.headers[.location] == "http://fish.com/chips/shrimp?fish=true")
68+
}
69+
70+
@Test
71+
func base_removes_serverPath() async throws {
72+
let handler: any HTTPHandler = .redirect(via: "http://fish.com", serverPath: "chips/shrimp")
73+
74+
var response = try await handler.handleRequest(.make(path: "/chips/shrimp/1/2", query: [.init(name: "fish", value: "true")]))
75+
#expect(response.statusCode == .movedPermanently)
76+
#expect(response.headers[.location] == "http://fish.com/1/2?fish=true")
77+
78+
response = try await handler.handleRequest(.make(path: "/chips/shrimp/1", query: [.init(name: "fish", value: "true")]))
79+
#expect(response.headers[.location] == "http://fish.com/1?fish=true")
80+
81+
await #expect(throws: URLError.self) {
82+
try await handler.handleRequest(.make(path: "/foo"))
83+
}
84+
}
85+
86+
@Test
87+
func base_statuscode() async throws {
88+
let handler = RedirectHTTPHandler(base: "http://fish.com", statusCode: .temporaryRedirect, serverPath: "/chips/shrimp")
89+
90+
let response = try await handler.handleRequest(.make(path: "/chips/shrimp"))
91+
#expect(response.statusCode == .temporaryRedirect)
92+
}
93+
}

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,22 @@ await server.appendRoute("GET /fish/*", to: .redirect(to: "https://pie.dev/get")
151151
// Location: https://pie.dev/get
152152
```
153153

154+
Or dynamically redirected via a base URL:
155+
156+
```swift
157+
await server.appendRoute("GET /fish/*", to: .redirect(via: "https://pie.dev"))
158+
// GET /fish/chips ---> HTTP 301
159+
// Location: https://pie.dev/fish/chips
160+
```
161+
162+
Providing a serverPath allows for the removal of a prefix before redirecting:
163+
164+
```swift
165+
await server.appendRoute("GET /fish/*", to: .redirect(via: "https://pie.dev", serverPath: "/fish"))
166+
// GET /fish/chips ---> HTTP 301
167+
// Location: https://pie.dev/chips
168+
```
169+
154170
### WebSocketHTTPHandler
155171

156172
Requests can be routed to a websocket by providing a `WSMessageHandler` where a pair of `AsyncStream<WSMessage>` are exchanged:

0 commit comments

Comments
 (0)