From e6434b3d0e4e015a9018e26b35191e64b2ecd825 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sun, 7 Apr 2024 11:08:33 -0400 Subject: [PATCH] feat: async http client (#22) * Try async * Async http client * mark sendable * Shutdown client * Use sync shutdown * Refactor body * Fix text body * Fix concurrency issues * Update readme with isolated --- Package.swift | 2 + README.md | 2 +- Sources/Vercel/Environment.swift | 8 +- Sources/Vercel/Fetch/Fetch.swift | 81 +++++++++++++------- Sources/Vercel/Fetch/FetchRequest.swift | 20 +++-- Sources/Vercel/Fetch/FetchResponse.swift | 33 +++++--- Sources/Vercel/Handlers/ExpressHandler.swift | 18 +++-- Sources/Vercel/Handlers/RequestHandler.swift | 6 +- Sources/Vercel/JWT/JWT.swift | 10 +-- Sources/Vercel/Request.swift | 6 ++ Sources/Vercel/Router/Router.swift | 2 +- Sources/Vercel/Vercel.swift | 3 - 12 files changed, 124 insertions(+), 67 deletions(-) diff --git a/Package.swift b/Package.swift index 4d40b0b..d7166ff 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-crypto", from: "3.0.0"), .package( url: "https://github.com/swift-server/swift-aws-lambda-runtime", from: "1.0.0-alpha.2"), + .package(url: "https://github.com/swift-server/async-http-client", from: "1.20.1"), .package(url: "https://github.com/vapor/vapor", from: "4.0.0"), ], targets: [ @@ -23,6 +24,7 @@ let package = Package( name: "Vercel", dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), + .product(name: "AsyncHTTPClient", package: "async-http-client"), .product(name: "Crypto", package: "swift-crypto"), ], swiftSettings: [ diff --git a/README.md b/README.md index 57401c1..c293426 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ import Vercel @main struct App: ExpressHandler { - static func configure(router: Router) async throws { + static func configure(router: isolated Router) async throws { router.get("/") { req, res in res.status(.ok).send("Hello, Swift") } diff --git a/Sources/Vercel/Environment.swift b/Sources/Vercel/Environment.swift index 01fe172..8253988 100644 --- a/Sources/Vercel/Environment.swift +++ b/Sources/Vercel/Environment.swift @@ -28,11 +28,11 @@ public struct VercelEnvironment: Sendable { extension VercelEnvironment { - public static var edgeConfig = Self["EDGE_CONFIG"]! + public static let edgeConfig = Self["EDGE_CONFIG"]! - public static var vercelEnvironment = Self["VERCEL_ENV", default: "dev"] + public static let vercelEnvironment = Self["VERCEL_ENV", default: "dev"] - public static var vercelHostname = Self["VERCEL_URL", default: "localhost"] + public static let vercelHostname = Self["VERCEL_URL", default: "localhost"] - public static var vercelRegion = Self["VERCEL_REGION", default: "dev1"] + public static let vercelRegion = Self["VERCEL_REGION", default: "dev1"] } diff --git a/Sources/Vercel/Fetch/Fetch.swift b/Sources/Vercel/Fetch/Fetch.swift index 3b5d312..144eb6d 100644 --- a/Sources/Vercel/Fetch/Fetch.swift +++ b/Sources/Vercel/Fetch/Fetch.swift @@ -6,14 +6,13 @@ // import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif +import AsyncHTTPClient public enum FetchError: Error, Sendable { case invalidResponse case invalidURL case timeout + case invalidLambdaContext } public func fetch(_ request: FetchRequest) async throws -> FetchResponse { @@ -42,56 +41,44 @@ public func fetch(_ request: FetchRequest) async throws -> FetchResponse { } // Set request resources - var httpRequest = URLRequest(url: url) + var httpRequest = HTTPClientRequest(url: url.absoluteString) // Set request method - httpRequest.httpMethod = request.method.rawValue - - // Set the timeout interval - if let timeoutInterval = request.timeoutInterval { - httpRequest.timeoutInterval = timeoutInterval - } + httpRequest.method = .init(rawValue: request.method.rawValue) // Set default content type based on body if let contentType = request.body?.defaultContentType { let name = HTTPHeaderKey.contentType.rawValue - httpRequest.setValue(request.headers[name] ?? contentType, forHTTPHeaderField: name) + httpRequest.headers.add(name: name, value: request.headers[name] ?? contentType) } // Set headers for (key, value) in request.headers { - httpRequest.setValue(value, forHTTPHeaderField: key) + httpRequest.headers.add(name: key, value: value) } // Write bytes to body switch request.body { case .bytes(let bytes): - httpRequest.httpBody = Data(bytes) + httpRequest.body = .bytes(bytes) case .data(let data): - httpRequest.httpBody = data + httpRequest.body = .bytes(data) case .text(let text): - httpRequest.httpBody = Data(text.utf8) + httpRequest.body = .bytes(text.utf8, length: .known(text.utf8.count)) case .json(let json): - httpRequest.httpBody = json + httpRequest.body = .bytes(json) case .none: break } - let (data, response): (Data, HTTPURLResponse) = try await withCheckedThrowingContinuation { continuation in - let task = URLSession.shared.dataTask(with: httpRequest) { data, response, error in - if let data, let response = response as? HTTPURLResponse { - continuation.resume(returning: (data, response)) - } else { - continuation.resume(throwing: error ?? FetchError.invalidResponse) - } - } - task.resume() - } + let httpClient = request.httpClient ?? HTTPClient.vercelClient + + let response = try await httpClient.execute(httpRequest, timeout: request.timeout ?? .seconds(60)) return FetchResponse( - body: data, - headers: response.allHeaderFields as! [String: String], - status: response.statusCode, + body: response.body, + headers: response.headers.reduce(into: [:]) { $0[$1.name] = $1.value }, + status: .init(response.status.code), url: url ) } @@ -108,3 +95,39 @@ public func fetch(_ urlPath: String, _ options: FetchRequest.Options = .options( let request = FetchRequest(url, options) return try await fetch(request) } + +extension HTTPClient { + + fileprivate static let vercelClient = HTTPClient( + eventLoopGroup: HTTPClient.defaultEventLoopGroup, + configuration: .vercelConfiguration + ) +} + +extension HTTPClient.Configuration { + /// The ``HTTPClient/Configuration`` for ``HTTPClient/shared`` which tries to mimic the platform's default or prevalent browser as closely as possible. + /// + /// Don't rely on specific values of this configuration as they're subject to change. You can rely on them being somewhat sensible though. + /// + /// - note: At present, this configuration is nowhere close to a real browser configuration but in case of disagreements we will choose values that match + /// the default browser as closely as possible. + /// + /// Platform's default/prevalent browsers that we're trying to match (these might change over time): + /// - macOS: Safari + /// - iOS: Safari + /// - Android: Google Chrome + /// - Linux (non-Android): Google Chrome + fileprivate static var vercelConfiguration: HTTPClient.Configuration { + // To start with, let's go with these values. Obtained from Firefox's config. + return HTTPClient.Configuration( + certificateVerification: .fullVerification, + redirectConfiguration: .follow(max: 20, allowCycles: false), + timeout: Timeout(connect: .seconds(90), read: .seconds(90)), + connectionPool: .seconds(600), + proxy: nil, + ignoreUncleanSSLShutdown: false, + decompression: .enabled(limit: .ratio(10)), + backgroundActivityLogger: nil + ) + } +} diff --git a/Sources/Vercel/Fetch/FetchRequest.swift b/Sources/Vercel/Fetch/FetchRequest.swift index 8617beb..a98ec2d 100644 --- a/Sources/Vercel/Fetch/FetchRequest.swift +++ b/Sources/Vercel/Fetch/FetchRequest.swift @@ -5,6 +5,9 @@ // Created by Andrew Barba on 1/22/23. // +import AsyncHTTPClient +import NIOCore + public struct FetchRequest: Sendable { public var url: URL @@ -17,7 +20,9 @@ public struct FetchRequest: Sendable { public var body: Body? - public var timeoutInterval: TimeInterval? = nil + public var timeout: TimeAmount? = nil + + public var httpClient: HTTPClient? = nil public init(_ url: URL, _ options: Options = .options()) { self.url = url @@ -25,7 +30,8 @@ public struct FetchRequest: Sendable { self.headers = options.headers self.searchParams = options.searchParams self.body = options.body - self.timeoutInterval = options.timeoutInterval + self.timeout = options.timeout + self.httpClient = options.httpClient } } @@ -41,21 +47,25 @@ extension FetchRequest { public var searchParams: [String: String] = [:] - public var timeoutInterval: TimeInterval? = nil + public var timeout: TimeAmount? = nil + + public var httpClient: HTTPClient? = nil public static func options( method: HTTPMethod = .GET, body: Body? = nil, headers: [String: String] = [:], searchParams: [String: String] = [:], - timeoutInterval: TimeInterval? = nil + timeout: TimeAmount? = nil, + httpClient: HTTPClient? = nil ) -> Options { return Options( method: method, body: body, headers: headers, searchParams: searchParams, - timeoutInterval: timeoutInterval + timeout: timeout, + httpClient: httpClient ) } } diff --git a/Sources/Vercel/Fetch/FetchResponse.swift b/Sources/Vercel/Fetch/FetchResponse.swift index dd2ae5b..6dec1bf 100644 --- a/Sources/Vercel/Fetch/FetchResponse.swift +++ b/Sources/Vercel/Fetch/FetchResponse.swift @@ -5,9 +5,13 @@ // Created by Andrew Barba on 1/22/23. // +import AsyncHTTPClient +import NIOCore +import NIOFoundationCompat + public struct FetchResponse: Sendable { - public let body: Data + public let body: HTTPClientResponse.Body public let headers: [String: String] @@ -26,27 +30,32 @@ extension FetchResponse { extension FetchResponse { public func decode(decoder: JSONDecoder = .init()) async throws -> T where T: Decodable & Sendable { - return try decoder.decode(T.self, from: body) + let bytes = try await self.bytes() + return try decoder.decode(T.self, from: bytes) } public func decode(_ type: T.Type, decoder: JSONDecoder = .init()) async throws -> T where T: Decodable & Sendable { - return try decoder.decode(type, from: body) + let bytes = try await self.bytes() + return try decoder.decode(type, from: bytes) } public func json() async throws -> Any { - return try JSONSerialization.jsonObject(with: body) + let bytes = try await self.bytes() + return try JSONSerialization.jsonObject(with: bytes) } public func jsonObject() async throws -> [String: Any] { - return try JSONSerialization.jsonObject(with: body) as! [String: Any] + let bytes = try await self.bytes() + return try JSONSerialization.jsonObject(with: bytes) as! [String: Any] } public func jsonArray() async throws -> [Any] { - return try JSONSerialization.jsonObject(with: body) as! [Any] + let bytes = try await self.bytes() + return try JSONSerialization.jsonObject(with: bytes) as! [Any] } public func formValues() async throws -> [String: String] { - let query = String(data: body, encoding: .utf8)! + let query = try await self.text() let components = URLComponents(string: "?\(query)") let queryItems = components?.queryItems ?? [] return queryItems.reduce(into: [:]) { values, item in @@ -55,14 +64,16 @@ extension FetchResponse { } public func text() async throws -> String { - return String(data: body, encoding: .utf8)! + var bytes = try await self.bytes() + return bytes.readString(length: bytes.readableBytes) ?? "" } public func data() async throws -> Data { - return body + var bytes = try await self.bytes() + return bytes.readData(length: bytes.readableBytes) ?? .init() } - public func bytes() async throws -> [UInt8] { - return Array(body) + public func bytes(upTo maxBytes: Int = .max) async throws -> ByteBuffer { + return try await body.collect(upTo: maxBytes) } } diff --git a/Sources/Vercel/Handlers/ExpressHandler.swift b/Sources/Vercel/Handlers/ExpressHandler.swift index 8b2f89c..d682102 100644 --- a/Sources/Vercel/Handlers/ExpressHandler.swift +++ b/Sources/Vercel/Handlers/ExpressHandler.swift @@ -10,8 +10,8 @@ import AWSLambdaRuntime public protocol ExpressHandler: RequestHandler { static var basePath: String { get } - - static func configure(router: Router) async throws + + static func configure(router: isolated Router) async throws } extension ExpressHandler { @@ -26,18 +26,24 @@ extension ExpressHandler { // Configure router in user code try await configure(router: router) // Cache the app instance - Shared.router = router + await Shared.default.setRouter(router) } public func onRequest(_ req: Request) async throws -> Response { - guard let router = Shared.router else { + guard let router = await Shared.default.router else { return .status(.serviceUnavailable).send("Express router not configured") } return try await router.run(req) } } -fileprivate struct Shared { +fileprivate actor Shared { + + static let `default` = Shared() - static var router: Router? + var router: Router? + + func setRouter(_ router: Router) { + self.router = router + } } diff --git a/Sources/Vercel/Handlers/RequestHandler.swift b/Sources/Vercel/Handlers/RequestHandler.swift index 4603bc0..8cf539f 100644 --- a/Sources/Vercel/Handlers/RequestHandler.swift +++ b/Sources/Vercel/Handlers/RequestHandler.swift @@ -8,7 +8,7 @@ import AWSLambdaRuntime import NIOCore -public protocol RequestHandler: EventLoopLambdaHandler where Event == InvokeEvent, Output == Response { +public protocol RequestHandler: Sendable & EventLoopLambdaHandler where Event == InvokeEvent, Output == Response { func onRequest(_ req: Request) async throws -> Response @@ -24,7 +24,9 @@ extension RequestHandler { let data = Data(event.body.utf8) let payload = try JSONDecoder().decode(InvokeEvent.Payload.self, from: data) let req = Request(payload, in: context) - return try await onRequest(req) + return try await Request.$current.withValue(req) { + return try await onRequest(req) + } } } diff --git a/Sources/Vercel/JWT/JWT.swift b/Sources/Vercel/JWT/JWT.swift index 2edf6fd..a21c525 100644 --- a/Sources/Vercel/JWT/JWT.swift +++ b/Sources/Vercel/JWT/JWT.swift @@ -54,7 +54,7 @@ public struct JWT: Sendable { } public init( - claims: [String: Any], + claims: [String: Sendable], secret: String, algorithm: Algorithm = .hs256, issuedAt: Date = .init(), @@ -63,12 +63,12 @@ public struct JWT: Sendable { subject: String? = nil, identifier: String? = nil ) throws { - let header: [String: Any] = [ + let header: [String: Sendable] = [ "alg": algorithm.rawValue, "typ": "JWT" ] - var properties: [String: Any] = [ + var properties: [String: Sendable] = [ "iat": floor(issuedAt.timeIntervalSince1970) ] @@ -191,9 +191,9 @@ extension JWT { } } -private func decodeJWTPart(_ value: String) throws -> [String: Any] { +private func decodeJWTPart(_ value: String) throws -> [String: Sendable] { let bodyData = try base64UrlDecode(value) - guard let json = try JSONSerialization.jsonObject(with: bodyData, options: []) as? [String: Any] else { + guard let json = try JSONSerialization.jsonObject(with: bodyData, options: []) as? [String: Sendable] else { throw JWTError.invalidJSON } return json diff --git a/Sources/Vercel/Request.swift b/Sources/Vercel/Request.swift index 24a8bf9..128e89f 100644 --- a/Sources/Vercel/Request.swift +++ b/Sources/Vercel/Request.swift @@ -89,3 +89,9 @@ extension Request { return headers[key.rawValue]?.value } } + +extension Request { + + @TaskLocal + public static var current: Request? +} diff --git a/Sources/Vercel/Router/Router.swift b/Sources/Vercel/Router/Router.swift index f718260..344da33 100644 --- a/Sources/Vercel/Router/Router.swift +++ b/Sources/Vercel/Router/Router.swift @@ -5,7 +5,7 @@ // Created by Andrew Barba on 1/22/23. // -public final class Router { +public actor Router { public typealias Handler = (Request, Response) async throws -> Response diff --git a/Sources/Vercel/Vercel.swift b/Sources/Vercel/Vercel.swift index 40ecad5..99b68db 100644 --- a/Sources/Vercel/Vercel.swift +++ b/Sources/Vercel/Vercel.swift @@ -6,6 +6,3 @@ // @_exported import Foundation -#if canImport(FoundationNetworking) -@_exported import FoundationNetworking -#endif