From 0b19c86ad5dd9cb647077999d91be9b2ceff4cac Mon Sep 17 00:00:00 2001 From: Simon Whitty Date: Sat, 13 Sep 2025 17:55:41 +1000 Subject: [PATCH] Dynamic Redirect --- FlyingFox/Sources/HTTPHandler.swift | 4 + .../Handlers/RedirectHTTPHandler.swift | 92 +++++++++++++++++- .../Handlers/RedirectHTTPHandlerTests.swift | 93 +++++++++++++++++++ README.md | 16 ++++ 4 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 FlyingFox/Tests/Handlers/RedirectHTTPHandlerTests.swift diff --git a/FlyingFox/Sources/HTTPHandler.swift b/FlyingFox/Sources/HTTPHandler.swift index 42530e14..4c88ef01 100644 --- a/FlyingFox/Sources/HTTPHandler.swift +++ b/FlyingFox/Sources/HTTPHandler.swift @@ -56,6 +56,10 @@ public extension HTTPHandler where Self == RedirectHTTPHandler { static func redirect(to location: String) -> RedirectHTTPHandler { RedirectHTTPHandler(location: location) } + + static func redirect(via base: String, serverPath: String? = nil) -> RedirectHTTPHandler { + RedirectHTTPHandler(base: base, serverPath: serverPath) + } } public extension HTTPHandler where Self == ProxyHTTPHandler { diff --git a/FlyingFox/Sources/Handlers/RedirectHTTPHandler.swift b/FlyingFox/Sources/Handlers/RedirectHTTPHandler.swift index 34f0e677..e80c2d8d 100644 --- a/FlyingFox/Sources/Handlers/RedirectHTTPHandler.swift +++ b/FlyingFox/Sources/Handlers/RedirectHTTPHandler.swift @@ -33,19 +33,101 @@ import Foundation public struct RedirectHTTPHandler: HTTPHandler { - private let location: String + private let destination: Destination + private let statusCode: HTTPStatusCode + private let serverPath: String? - public init(location: String) { - self.location = location + public init(base: String, statusCode: HTTPStatusCode = .movedPermanently, serverPath: String? = nil) { + self.destination = .base(base, serverPath: serverPath) + self.statusCode = statusCode + self.serverPath = serverPath + } + + public init(location: String, statusCode: HTTPStatusCode = .movedPermanently) { + self.destination = .location(location) + self.statusCode = statusCode + self.serverPath = nil } public func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse { - guard let url = URL(string: location) else { + switch destination { + case let .location(location): + guard let url = URL(string: location) else { + throw URLError(.badURL) + } + return try handleRedirect(to: url) + case let .base(base, serverPath): + let url = try makeRedirectLocation(for: request, via: base, serverPath: serverPath ?? "") + return try handleRedirect(to: url) + } + } + + private enum Destination { + case location(String) + case base(String, serverPath: String?) + } + + private func handleRedirect(to url: URL) throws -> HTTPResponse { + guard Self.isRedirect(statusCode) else { throw URLError(.badURL) } return HTTPResponse( - statusCode: .movedPermanently, + statusCode: statusCode, headers: [.location: url.absoluteString] ) } + + private func makeRedirectLocation(for request: HTTPRequest, via base: String, serverPath: String) throws -> URL { + guard let base = URL(string: base) else { + throw URLError(.badURL) + } + + let compsA = serverPath + .split(separator: "/", omittingEmptySubsequences: true) + .joined(separator: "/") + + let compsB = request.path + .split(separator: "/", omittingEmptySubsequences: true) + .joined(separator: "/") + + guard !compsA.isEmpty else { + return try base.appendingRequest(request) + } + + guard compsB.hasPrefix(compsA) else { + throw URLError(.badURL) + } + + var request = request + request.path = String(compsB.dropFirst(compsA.count)) + return try base.appendingRequest(request) + } + + static let redirectStatusCodes: Set = [.movedPermanently, .found, .seeOther, .temporaryRedirect, .permanentRedirect] + static func isRedirect(_ code: HTTPStatusCode) -> Bool { + redirectStatusCodes.contains(code) + } +} + +private extension URL { + + func appendingRequest(_ request: HTTPRequest) throws -> URL { + guard var comps = URLComponents(url: appendingPathComponent(request.path), resolvingAgainstBaseURL: false) else { + throw URLError(.badURL) + } + + var items = comps.queryItems ?? [] + items.append( + contentsOf: request.query.map { URLQueryItem(name: $0.name, value: $0.value) } + ) + + if !items.isEmpty { + comps.queryItems = items + } + + guard let url = comps.url else { + throw URLError(.badURL) + } + return url + } } diff --git a/FlyingFox/Tests/Handlers/RedirectHTTPHandlerTests.swift b/FlyingFox/Tests/Handlers/RedirectHTTPHandlerTests.swift new file mode 100644 index 00000000..f3d27fd8 --- /dev/null +++ b/FlyingFox/Tests/Handlers/RedirectHTTPHandlerTests.swift @@ -0,0 +1,93 @@ +// +// RedirectHTTPHandlerTests.swift +// FlyingFox +// +// Created by Simon Whitty on 13/09/2025. +// Copyright © 2025 Simon Whitty. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +@testable import FlyingFox +import Foundation +import Testing + +struct RedirectHTTPHandlerTests { + + @Test + func location_is_not_replaced() async throws { + let handler = RedirectHTTPHandler(location: "http://www.fish.com") + + var response = try await handler.handleRequest(.make(path: "/")) + #expect(response.statusCode == .movedPermanently) + #expect(response.headers[.location] == "http://www.fish.com") + + response = try await handler.handleRequest(.make(path: "/chips", query: [.init(name: "fish", value: "true")])) + #expect(response.headers[.location] == "http://www.fish.com") + } + + @Test + func location_statuscode() async throws { + let handler = RedirectHTTPHandler(location: "http://www.fish.com", statusCode: .temporaryRedirect) + + let response = try await handler.handleRequest(.make(path: "/")) + #expect(response.statusCode == .temporaryRedirect) + } + + @Test + func base_appends_request() async throws { + let handler: any HTTPHandler = .redirect(via: "http://fish.com") + + var response = try await handler.handleRequest(.make(path: "/chips/shrimp")) + #expect(response.statusCode == .movedPermanently) + #expect(response.headers[.location] == "http://fish.com/chips/shrimp") + + response = try await handler.handleRequest(.make(path: "/chips/shrimp", query: [.init(name: "fish", value: "true")])) + #expect(response.headers[.location] == "http://fish.com/chips/shrimp?fish=true") + } + + @Test + func base_removes_serverPath() async throws { + let handler: any HTTPHandler = .redirect(via: "http://fish.com", serverPath: "chips/shrimp") + + var response = try await handler.handleRequest(.make(path: "/chips/shrimp/1/2", query: [.init(name: "fish", value: "true")])) + #expect(response.statusCode == .movedPermanently) + #expect(response.headers[.location] == "http://fish.com/1/2?fish=true") + + response = try await handler.handleRequest(.make(path: "/chips/shrimp/1", query: [.init(name: "fish", value: "true")])) + #expect(response.headers[.location] == "http://fish.com/1?fish=true") + + await #expect(throws: URLError.self) { + try await handler.handleRequest(.make(path: "/foo")) + } + } + + @Test + func base_statuscode() async throws { + let handler = RedirectHTTPHandler(base: "http://fish.com", statusCode: .temporaryRedirect, serverPath: "/chips/shrimp") + + let response = try await handler.handleRequest(.make(path: "/chips/shrimp")) + #expect(response.statusCode == .temporaryRedirect) + } +} diff --git a/README.md b/README.md index 49fbc775..cbfe578d 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,22 @@ await server.appendRoute("GET /fish/*", to: .redirect(to: "https://pie.dev/get") // Location: https://pie.dev/get ``` +Or dynamically redirected via a base URL: + +```swift +await server.appendRoute("GET /fish/*", to: .redirect(via: "https://pie.dev")) +// GET /fish/chips ---> HTTP 301 +// Location: https://pie.dev/fish/chips +``` + +Providing a serverPath allows for the removal of a prefix before redirecting: + +```swift +await server.appendRoute("GET /fish/*", to: .redirect(via: "https://pie.dev", serverPath: "/fish")) +// GET /fish/chips ---> HTTP 301 +// Location: https://pie.dev/chips +``` + ### WebSocketHTTPHandler Requests can be routed to a websocket by providing a `WSMessageHandler` where a pair of `AsyncStream` are exchanged: