Skip to content

Commit b590424

Browse files
Alexander-IgnitionVasily Fedorovmodestman
authored
Simple HTTP Proxy (#49)
* draft proxy * fix proxy * Decompress response before recording & remove header on forwarding to requester Signed-off-by: Alexander Ignition <[email protected]> * Update AppConfiguration * Update ProxyMiddleware * Update Loggers.swift Add MultiplexLogHandler * Add text proxy * Update README.md * Update Proxy example in README.md Co-authored-by: Anton Glezman <[email protected]> Co-authored-by: Vasily Fedorov <[email protected]> Co-authored-by: Anton Glezman <[email protected]>
1 parent 787b830 commit b590424

14 files changed

+333
-75
lines changed

Packages/CatbirdApp/Sources/CatbirdApp/AppConfiguration.swift

+14-12
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
1-
import Foundation
1+
import Vapor
22

33
/// Application configuration.
44
public struct AppConfiguration {
55

6-
/// Application work mode.
7-
public enum Mode: Equatable {
8-
case write(URL)
9-
case read
10-
}
6+
public let isRecordMode: Bool
117

12-
/// Application work mode.
13-
public let mode: Mode
8+
public let proxyEnabled: Bool
149

1510
/// The directory for mocks.
1611
public let mocksDirectory: URL
1712

13+
public let redirectUrl: URL?
14+
1815
public let maxBodySize: String
1916
}
2017

@@ -38,11 +35,16 @@ extension AppConfiguration {
3835
return url
3936
}()
4037

38+
let isRecordMode = environment["CATBIRD_RECORD_MODE"].flatMap { NSString(string: $0).boolValue } ?? false
39+
let proxyEnabled = environment["CATBIRD_PROXY_ENABLED"].flatMap { NSString(string: $0).boolValue } ?? false
40+
let redirectUrl = environment["CATBIRD_REDIRECT_URL"].flatMap { URL(string: $0) }
4141
let maxBodySize = environment["CATBIRD_MAX_BODY_SIZE", default: "50mb"]
4242

43-
if let path = environment["CATBIRD_PROXY_URL"], let url = URL(string: path) {
44-
return AppConfiguration(mode: .write(url), mocksDirectory: mocksDirectory, maxBodySize: maxBodySize)
45-
}
46-
return AppConfiguration(mode: .read, mocksDirectory: mocksDirectory, maxBodySize: maxBodySize)
43+
return AppConfiguration(
44+
isRecordMode: isRecordMode,
45+
proxyEnabled: proxyEnabled,
46+
mocksDirectory: mocksDirectory,
47+
redirectUrl: redirectUrl,
48+
maxBodySize: maxBodySize)
4749
}
4850
}

Packages/CatbirdApp/Sources/CatbirdApp/Common/FileDirectoryPath.swift

+14-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ struct FileDirectoryPath {
99
}
1010

1111
func preferredFileURL(for request: Request) -> URL {
12-
var fileUrl = url.appendingPathComponent(request.url.string)
12+
var fileUrl = fileURL(for: request)
1313

1414
guard fileUrl.pathExtension.isEmpty else {
1515
return fileUrl
@@ -21,7 +21,7 @@ struct FileDirectoryPath {
2121
}
2222

2323
func filePaths(for request: Request) -> [String] {
24-
let fileUrl = url.appendingPathComponent(request.url.string)
24+
let fileUrl = fileURL(for: request)
2525

2626
var urls: [URL] = []
2727
if fileUrl.pathExtension.isEmpty {
@@ -32,4 +32,16 @@ struct FileDirectoryPath {
3232
urls.append(fileUrl)
3333
return urls.map { $0.absoluteString }
3434
}
35+
36+
private func fileURL(for request: Request) -> URL {
37+
var fileUrl = url
38+
if let host = request.url.host {
39+
fileUrl.appendPathComponent(host)
40+
}
41+
fileUrl.appendPathComponent(request.url.path)
42+
if fileUrl.absoluteString.hasSuffix("/") {
43+
fileUrl.appendPathComponent("index")
44+
}
45+
return fileUrl
46+
}
3547
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Vapor
2+
3+
extension Request {
4+
/// Send HTTP request.
5+
///
6+
/// - Parameter configure: client request configuration function.
7+
/// - Returns: Server response.
8+
func send(configure: ((inout ClientRequest) -> Void)? = nil) -> EventLoopFuture<Response> {
9+
return body
10+
.collect(max: nil)
11+
.flatMap { (bytesBuffer: ByteBuffer?) -> EventLoopFuture<Response> in
12+
var clientRequest = self.clientRequest(body: bytesBuffer)
13+
configure?(&clientRequest)
14+
return self.client.send(clientRequest).map { (clientResponse: ClientResponse) -> Response in
15+
clientResponse.response(version: self.version)
16+
}
17+
}
18+
}
19+
20+
/// Convert to HTTP client request.
21+
private func clientRequest(body: ByteBuffer?) -> ClientRequest {
22+
var headers = self.headers
23+
if let host = headers.first(name: "Host") {
24+
headers.replaceOrAdd(name: "X-Forwarded-Host", value: host)
25+
headers.remove(name: "Host")
26+
}
27+
return ClientRequest(method: method, url: url, headers: headers, body: body)
28+
}
29+
}
30+
31+
extension HTTPHeaders {
32+
fileprivate var contentLength: Int? {
33+
first(name: "Content-Length").flatMap { Int($0) }
34+
}
35+
}
36+
37+
extension ClientResponse {
38+
/// Convert to Server Response.
39+
fileprivate func response(version: HTTPVersion) -> Response {
40+
let body = body.map { Response.Body(buffer: $0) } ?? .empty
41+
return Response(status: status, version: version, headers: headers, body: body)
42+
}
43+
}

Packages/CatbirdApp/Sources/CatbirdApp/Common/Loggers.swift

+4-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ enum Loggers {
1313
return Logging.Logger(label: CatbirdInfo.current.domain)
1414
#else
1515
return Logging.Logger(label: CatbirdInfo.current.domain) {
16-
OSLogHandler(subsystem: $0, category: category)
16+
Logging.MultiplexLogHandler([
17+
OSLogHandler(subsystem: $0, category: category),
18+
Logging.StreamLogHandler.standardOutput(label: $0)
19+
])
1720
}
1821
#endif
1922
}

Packages/CatbirdApp/Sources/CatbirdApp/Middlewares/AnyMiddleware.swift

+25-15
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import Vapor
22

33
final class AnyMiddleware: Middleware {
44

5-
private typealias Handler = (Request, Responder) -> EventLoopFuture<Response>
5+
typealias Handler = (Request, Responder) -> EventLoopFuture<Response>
66

77
private let handler: Handler
88

9-
private init(handler: @escaping Handler) {
9+
init(handler: @escaping Handler) {
1010
self.handler = handler
1111
}
1212

@@ -24,19 +24,9 @@ extension AnyMiddleware {
2424
/// - Returns: A new `Middleware`.
2525
static func notFound(_ handler: @escaping (Request) -> EventLoopFuture<Response>) -> Middleware {
2626
AnyMiddleware { (request, responder) -> EventLoopFuture<Response> in
27-
responder.respond(to: request)
28-
.flatMap { (response: Response) -> EventLoopFuture<Response> in
29-
if response.status == .notFound {
30-
return handler(request)
31-
}
32-
return request.eventLoop.makeSucceededFuture(response)
33-
}
34-
.flatMapError { (error: Error) -> EventLoopFuture<Response> in
35-
if let abort = error as? AbortError, abort.status == .notFound {
36-
return handler(request)
37-
}
38-
return request.eventLoop.makeFailedFuture(error)
39-
}
27+
responder.respond(to: request).notFound {
28+
handler(request)
29+
}
4030
}
4131
}
4232

@@ -49,3 +39,23 @@ extension AnyMiddleware {
4939
}
5040

5141
}
42+
43+
extension EventLoopFuture where Value: Response {
44+
func notFound(
45+
_ handler: @escaping () -> EventLoopFuture<Response>
46+
) -> EventLoopFuture<Response> {
47+
48+
return flatMap { [eventLoop] (response: Response) -> EventLoopFuture<Response> in
49+
if response.status == .notFound {
50+
return handler()
51+
}
52+
return eventLoop.makeSucceededFuture(response)
53+
}
54+
.flatMapError { [eventLoop] (error: Error) -> EventLoopFuture<Response> in
55+
if let abort = error as? AbortError, abort.status == .notFound {
56+
return handler()
57+
}
58+
return eventLoop.makeFailedFuture(error)
59+
}
60+
}
61+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Vapor
2+
3+
final class ProxyMiddleware: Middleware {
4+
5+
func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture<Response> {
6+
if request.url.host == nil {
7+
request.logger.info("Proxy break \(request.method) \(request.url)")
8+
return next.respond(to: request)
9+
}
10+
return next.respond(to: request).notFound {
11+
var url = request.url
12+
if url.scheme == nil {
13+
url.scheme = url.port == 443 ? "https" : "http"
14+
}
15+
16+
request.logger.info("Proxy \(request.method) \(url), scheme \(url.scheme ?? "<nil>")")
17+
18+
// Send request to real host
19+
return request.send {
20+
$0.url = url
21+
}
22+
}
23+
}
24+
}

Packages/CatbirdApp/Sources/CatbirdApp/Middlewares/RedirectMiddleware.swift

+9-21
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,17 @@ final class RedirectMiddleware: Middleware {
1111
// MARK: - Middleware
1212

1313
func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture<Response> {
14-
return request.body.collect(max: nil).flatMap { (body: ByteBuffer?) -> EventLoopFuture<Response> in
15-
var headers = request.headers
16-
headers.remove(name: "Host")
17-
18-
var clientRequest = ClientRequest(
19-
method: request.method,
20-
url: self.redirectURI,
21-
headers: headers,
22-
body: request.body.data)
14+
// Handle only direct requests to catbird
15+
if request.url.host != nil {
16+
return next.respond(to: request) // proxy request
17+
}
2318

24-
clientRequest.url.string += request.url.string
19+
var uri = redirectURI
20+
uri.string += request.url.string
2521

26-
return request
27-
.client
28-
.send(clientRequest)
29-
.map { (response: ClientResponse) -> Response in
30-
let body = response.body.map { Response.Body(buffer: $0) } ?? .empty
31-
return Response(
32-
status: response.status,
33-
version: request.version,
34-
headers: response.headers,
35-
body: body)
36-
}
22+
// Send request to redirect host
23+
return request.send {
24+
$0.url = uri
3725
}
3826
}
3927
}

Packages/CatbirdApp/Sources/CatbirdApp/configure.swift

+24-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import CatbirdAPI
22
import Vapor
3+
import NIOSSL
34

45
public struct CatbirdInfo: Content {
56
public static let current = CatbirdInfo(
@@ -28,26 +29,38 @@ public func configure(_ app: Application, _ configuration: AppConfiguration) thr
2829
store: InMemoryResponseStore(),
2930
logger: Loggers.inMemoryStore)
3031

31-
// MARK: - Register Middlewares
32+
// MARK: - Register Middleware
3233

3334
// Pubic resource for web page
3435
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
35-
switch configuration.mode {
36-
case .read:
37-
app.logger.info("Read mode")
38-
// try read from static mocks if route not found
39-
app.middleware.use(AnyMiddleware.notFound(fileStore.response))
40-
// try read from dynamic mocks
41-
app.middleware.use(AnyMiddleware.notFound(inMemoryStore.response))
42-
case .write(let url):
43-
app.logger.info("Write mode")
36+
if configuration.isRecordMode {
37+
app.logger.info("Record mode")
38+
app.http.client.configuration.decompression = .enabled(limit: .none)
4439
// capture response and write to file
4540
app.middleware.use(AnyMiddleware.capture { request, response in
41+
if response.headers.contains(name: "Content-encoding") {
42+
response.headers.remove(name: "Content-encoding")
43+
}
4644
let pattern = RequestPattern(method: .init(request.method.rawValue), url: request.url.string)
4745
let mock = ResponseMock(status: Int(response.status.code), body: response.body.data)
4846
return fileStore.perform(.update(pattern, mock), for: request).map { _ in response }
4947
})
50-
// redirect request to another server
48+
// catch 404 and try read from real server
49+
if configuration.proxyEnabled {
50+
app.middleware.use(ProxyMiddleware())
51+
}
52+
} else {
53+
app.logger.info("Read mode")
54+
// catch 404 and try read from real server
55+
if configuration.proxyEnabled {
56+
app.middleware.use(ProxyMiddleware())
57+
}
58+
// try read from static mocks if route not found
59+
app.middleware.use(AnyMiddleware.notFound(fileStore.response))
60+
// try read from dynamic mocks
61+
app.middleware.use(AnyMiddleware.notFound(inMemoryStore.response))
62+
}
63+
if let url = configuration.redirectUrl {
5164
app.middleware.use(RedirectMiddleware(serverURL: url))
5265
}
5366

Packages/CatbirdApp/Tests/CatbirdAppTests/App/AppConfigurationTests.swift

+9-3
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,24 @@ final class AppConfigurationTests: XCTestCase {
55

66
func testDetectReadMode() throws {
77
let config = try AppConfiguration.detect(from: [:])
8-
XCTAssertEqual(config.mode, .read)
8+
XCTAssertEqual(config.isRecordMode, false)
9+
XCTAssertEqual(config.proxyEnabled, false)
910
XCTAssertEqual(config.mocksDirectory.absoluteString, AppConfiguration.sourceDir)
11+
XCTAssertNil(config.redirectUrl)
1012
XCTAssertEqual(config.maxBodySize, "50mb")
1113
}
1214

1315
func testDetectWriteMode() throws {
1416
let config = try AppConfiguration.detect(from: [
15-
"CATBIRD_PROXY_URL": "/",
17+
"CATBIRD_RECORD_MODE": "1",
18+
"CATBIRD_PROXY_ENABLED": "1",
19+
"CATBIRD_REDIRECT_URL": "https://example.com",
1620
"CATBIRD_MAX_BODY_SIZE": "1kb"
1721
])
18-
XCTAssertEqual(config.mode, .write(URL(string: "/")!))
22+
XCTAssertEqual(config.isRecordMode, true)
23+
XCTAssertEqual(config.proxyEnabled, true)
1924
XCTAssertEqual(config.mocksDirectory.absoluteString, AppConfiguration.sourceDir)
25+
XCTAssertEqual(config.redirectUrl?.absoluteString, "https://example.com")
2026
XCTAssertEqual(config.maxBodySize, "1kb")
2127
}
2228

Packages/CatbirdApp/Tests/CatbirdAppTests/App/AppTestCase.swift

+9-3
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,24 @@ class AppTestCase: XCTestCase {
99
willSet { app?.shutdown() }
1010
}
1111

12-
func setUpApp(mode: AppConfiguration.Mode) throws {
12+
func setUpApp(
13+
isRecordMode: Bool = false,
14+
proxyEnabled: Bool = false,
15+
redirectUrl: URL? = nil
16+
) throws {
1317
let config = AppConfiguration(
14-
mode: mode,
18+
isRecordMode: isRecordMode,
19+
proxyEnabled: proxyEnabled,
1520
mocksDirectory: URL(string: mocksDirectory)!,
21+
redirectUrl: redirectUrl,
1622
maxBodySize: "50kb")
1723
app = Application(.testing)
1824
try configure(app, config)
1925
}
2026

2127
override func setUp() {
2228
super.setUp()
23-
XCTAssertNoThrow(try setUpApp(mode: .read))
29+
XCTAssertNoThrow(try setUpApp())
2430
XCTAssertEqual(app.routes.defaultMaxBodySize, 51200)
2531
}
2632

0 commit comments

Comments
 (0)