Skip to content

Commit

Permalink
feat: async http client (#22)
Browse files Browse the repository at this point in the history
* Try async

* Async http client

* mark sendable

* Shutdown client

* Use sync shutdown

* Refactor body

* Fix text body

* Fix concurrency issues

* Update readme with isolated
  • Loading branch information
AndrewBarba authored Apr 7, 2024
1 parent 2f655ca commit e6434b3
Show file tree
Hide file tree
Showing 12 changed files with 124 additions and 67 deletions.
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ 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: [
.target(
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: [
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
8 changes: 4 additions & 4 deletions Sources/Vercel/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
81 changes: 52 additions & 29 deletions Sources/Vercel/Fetch/Fetch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
)
}
Expand All @@ -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
)
}
}
20 changes: 15 additions & 5 deletions Sources/Vercel/Fetch/FetchRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
// Created by Andrew Barba on 1/22/23.
//

import AsyncHTTPClient
import NIOCore

public struct FetchRequest: Sendable {

public var url: URL
Expand All @@ -17,15 +20,18 @@ 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
self.method = options.method
self.headers = options.headers
self.searchParams = options.searchParams
self.body = options.body
self.timeoutInterval = options.timeoutInterval
self.timeout = options.timeout
self.httpClient = options.httpClient
}
}

Expand All @@ -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
)
}
}
Expand Down
33 changes: 22 additions & 11 deletions Sources/Vercel/Fetch/FetchResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -26,27 +30,32 @@ extension FetchResponse {
extension FetchResponse {

public func decode<T>(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<T>(_ 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
Expand All @@ -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)
}
}
18 changes: 12 additions & 6 deletions Sources/Vercel/Handlers/ExpressHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}
6 changes: 4 additions & 2 deletions Sources/Vercel/Handlers/RequestHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
}
}
}

Expand Down
10 changes: 5 additions & 5 deletions Sources/Vercel/JWT/JWT.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public struct JWT: Sendable {
}

public init(
claims: [String: Any],
claims: [String: Sendable],
secret: String,
algorithm: Algorithm = .hs256,
issuedAt: Date = .init(),
Expand All @@ -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)
]

Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit e6434b3

Please sign in to comment.