Skip to content

Commit 21e73ba

Browse files
Add FunctionURLLambda (#48)
* Add FunctionURLLambda * Add tests * Update Sources/HummingbirdLambdaTesting/FunctionURLLambda.swift * Update FunctionURLLambda to work with latest code --------- Co-authored-by: Adam Fowler <[email protected]>
1 parent ddf82b3 commit 21e73ba

File tree

6 files changed

+260
-1
lines changed

6 files changed

+260
-1
lines changed

Sources/HummingbirdLambda/APIGatewayLambda.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ import NIOCore
1818
import NIOHTTP1
1919

2020
/// Typealias for Lambda function triggered by APIGateway
21+
///
22+
/// ```swift
23+
/// let router = Router(context: BasicLambdaRequestContext<APIGatewayRequest>.self)
24+
/// router.get { request, context in
25+
/// "Hello!"
26+
/// }
27+
/// let lambda = APIGatewayLambdaFunction(router: router)
28+
/// try await lambda.runService()
29+
/// ```
2130
public typealias APIGatewayLambdaFunction<Responder: HTTPResponder> = LambdaFunction<Responder, APIGatewayRequest, APIGatewayResponse>
2231
where Responder.Context: InitializableFromSource<LambdaRequestContextSource<APIGatewayRequest>>
2332

Sources/HummingbirdLambda/APIGatewayV2Lambda.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ import NIOCore
1818
import NIOHTTP1
1919

2020
/// Typealias for Lambda function triggered by APIGatewayV2
21+
///
22+
/// ```swift
23+
/// let router = Router(context: BasicLambdaRequestContext<APIGatewayV2Request>.self)
24+
/// router.get { request, context in
25+
/// "Hello!"
26+
/// }
27+
/// let lambda = APIGatewayV2LambdaFunction(router: router)
28+
/// try await lambda.runService()
29+
/// ```
2130
public typealias APIGatewayV2LambdaFunction<Responder: HTTPResponder> = LambdaFunction<Responder, APIGatewayV2Request, APIGatewayV2Response>
2231
where Responder.Context: InitializableFromSource<LambdaRequestContextSource<APIGatewayV2Request>>
2332

@@ -48,7 +57,7 @@ extension APIGatewayV2Response: APIResponse {
4857
body: String?,
4958
isBase64Encoded: Bool?
5059
) {
51-
precondition(multiValueHeaders == nil || multiValueHeaders?.count == 0, "Multi value headers are unavailable in APIGatewayV2")
60+
precondition(multiValueHeaders == nil || multiValueHeaders?.isEmpty == true, "Multi value headers are unavailable in APIGatewayV2")
5261
self.init(statusCode: statusCode, headers: headers, body: body, isBase64Encoded: isBase64Encoded, cookies: nil)
5362
}
5463
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Hummingbird server framework project
4+
//
5+
// Copyright (c) 2021-2024 the Hummingbird authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import AWSLambdaEvents
16+
import Hummingbird
17+
import NIOCore
18+
import NIOHTTP1
19+
20+
/// Typealias for Lambda function triggered by function URL
21+
///
22+
/// ```swift
23+
/// let router = Router(context: BasicLambdaRequestContext<FunctionURLRequest>.self)
24+
/// router.get { request, context in
25+
/// "Hello!"
26+
/// }
27+
/// let lambda = FunctionURLLambdaFunction(router: router)
28+
/// try await lambda.runService()
29+
/// ```
30+
public typealias FunctionURLLambdaFunction<Responder: HTTPResponder> = LambdaFunction<Responder, FunctionURLRequest, FunctionURLResponse>
31+
where Responder.Context: InitializableFromSource<LambdaRequestContextSource<FunctionURLRequest>>
32+
33+
// conform `FunctionURLRequest` to `APIRequest` so we can use Request.init(context:application:from)
34+
extension FunctionURLRequest: APIRequest {
35+
var path: String {
36+
requestContext.http.path
37+
}
38+
39+
var httpMethod: HTTPRequest.Method { requestContext.http.method }
40+
var queryString: String { self.rawQueryString }
41+
var httpHeaders: [(name: String, value: String)] {
42+
self.headers.flatMap { header in
43+
let headers = header.value
44+
.split(separator: ",")
45+
.map { (name: header.key, value: String($0.drop(while: \.isWhitespace))) }
46+
return headers
47+
}
48+
}
49+
}
50+
51+
// conform `FunctionURLResponse` to `APIResponse` so we can use Response.apiReponse()
52+
extension FunctionURLResponse: APIResponse {
53+
package init(
54+
statusCode: HTTPResponse.Status,
55+
headers: AWSLambdaEvents.HTTPHeaders?,
56+
multiValueHeaders: HTTPMultiValueHeaders?,
57+
body: String?,
58+
isBase64Encoded: Bool?
59+
) {
60+
precondition(multiValueHeaders == nil || multiValueHeaders?.isEmpty == true, "Multi value headers are unavailable in FunctionURL")
61+
self.init(statusCode: statusCode, headers: headers, body: body, cookies: nil, isBase64Encoded: isBase64Encoded)
62+
}
63+
}

Sources/HummingbirdLambdaTesting/APIGatewayV2Lambda.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ extension APIGatewayV2Request: LambdaTestableEvent {
6969
"domainName":"hello.test.com",
7070
"apiId":"pb5dg6g3rg",
7171
"requestId":"LgLpnibOFiAEPCA=",
72+
"routeKey":"\(method) \(url.path)",
7273
"http":{
7374
"path":"\(url.path)",
7475
"userAgent":"Paw/3.1.10 (Macintosh; OS X/10.15.4) GCDHTTPRequest",
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Hummingbird server framework project
4+
//
5+
// Copyright (c) 2021-2024 the Hummingbird authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import AWSLambdaEvents
16+
import ExtrasBase64
17+
import Foundation
18+
import HTTPTypes
19+
import HummingbirdCore
20+
import NIOCore
21+
22+
#if canImport(FoundationEssentials)
23+
import FoundationEssentials
24+
#else
25+
import Foundation
26+
#endif
27+
28+
extension FunctionURLRequest: LambdaTestableEvent {
29+
/// Construct FunctionURL Event from uri, method, headers and body
30+
public init(uri: String, method: HTTPRequest.Method, headers: HTTPFields, body: ByteBuffer?) throws {
31+
let base64Body = body.map { "\"\(Base64.encodeToString(bytes: $0.readableBytesView))\"" } ?? "null"
32+
let url = URI(uri)
33+
let queryValues: [String: [String]] = url.queryParameters.reduce([:]) { result, value in
34+
var result = result
35+
let key = String(value.key)
36+
var values = result[key] ?? []
37+
values.append(.init(value.value))
38+
result[key] = values
39+
return result
40+
}
41+
let queryValueStrings = try String(decoding: JSONEncoder().encode(queryValues.mapValues { $0.joined(separator: ",") }), as: UTF8.self)
42+
let headerValues: [String: [String]] = headers.reduce(["host": ["127.0.0.1:8080"]]) { result, value in
43+
var result = result
44+
let key = String(value.name)
45+
var values = result[key] ?? []
46+
values.append(.init(value.value))
47+
result[key] = values
48+
return result
49+
}
50+
let headerValueStrings = try String(decoding: JSONEncoder().encode(headerValues.mapValues { $0.joined(separator: ",") }), as: UTF8.self)
51+
let eventJson = """
52+
{
53+
"routeKey":"\(method) \(url.path)",
54+
"version":"2.0",
55+
"rawPath":"\(url.path)",
56+
"stageVariables":null,
57+
"requestContext":{
58+
"timeEpoch":1587750461466,
59+
"domainPrefix":"hello",
60+
"authorizer":{
61+
"iam": {
62+
"accessKey": "AKIA...",
63+
"accountId": "111122223333",
64+
"callerId": "AIDA...",
65+
"cognitoIdentity": null,
66+
"principalOrgId": null,
67+
"userArn": "arn:aws:iam::111122223333:user/example-user",
68+
"userId": "AIDA..."
69+
}
70+
},
71+
"routeKey":"\(method) \(url.path)",
72+
"accountId":"0123456789",
73+
"stage":"$default",
74+
"domainName":"hello.test.com",
75+
"apiId":"pb5dg6g3rg",
76+
"requestId":"LgLpnibOFiAEPCA=",
77+
"http":{
78+
"path":"\(url.path)",
79+
"userAgent":"Paw/3.1.10 (Macintosh; OS X/10.15.4) GCDHTTPRequest",
80+
"method":"\(method)",
81+
"protocol":"HTTP/1.1",
82+
"sourceIp":"91.64.117.86"
83+
},
84+
"time":"24/Apr/2020:17:47:41 +0000"
85+
},
86+
"body": \(base64Body),
87+
"isBase64Encoded": \(body != nil),
88+
"rawQueryString":"\(url.query ?? "")",
89+
"queryStringParameters":\(queryValueStrings),
90+
"headers":\(headerValueStrings)
91+
}
92+
"""
93+
self = try JSONDecoder().decode(Self.self, from: Data(eventJson.utf8))
94+
}
95+
}

Tests/HummingbirdLambdaTests/LambdaTests.swift

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,88 @@ final class LambdaTests: XCTestCase {
205205
}
206206
}
207207

208+
func testSimpleRouteURLFunction() async throws {
209+
let router = Router(context: BasicLambdaRequestContext<FunctionURLRequest>.self)
210+
router.middlewares.add(LogRequestsMiddleware(.debug))
211+
router.post { request, _ in
212+
XCTAssertEqual(request.head.authority, "127.0.0.1:8080")
213+
return ["response": "hello"]
214+
}
215+
let lambda = FunctionURLLambdaFunction(router: router)
216+
try await lambda.test { client in
217+
try await client.execute(uri: "/", method: .post) { response in
218+
XCTAssertEqual(response.statusCode, .ok)
219+
XCTAssertEqual(response.headers?["Content-Type"], "application/json; charset=utf-8")
220+
XCTAssertEqual(response.body, #"{"response":"hello"}"#)
221+
}
222+
}
223+
}
224+
225+
func testBase64EncodingURLFunction() async throws {
226+
let router = Router(context: BasicLambdaRequestContext<FunctionURLRequest>.self)
227+
router.middlewares.add(LogRequestsMiddleware(.debug))
228+
router.post { request, _ in
229+
let buffer = try await request.body.collect(upTo: .max)
230+
return Response(status: .ok, body: .init(byteBuffer: buffer))
231+
}
232+
let lambda = FunctionURLLambdaFunction(router: router)
233+
try await lambda.test { client in
234+
let body = ByteBuffer(bytes: (0...255).map { _ in UInt8.random(in: 0...255) })
235+
try await client.execute(uri: "/", method: .post, headers: [.userAgent: "HBXCT/2.0"], body: body) { response in
236+
XCTAssertEqual(response.isBase64Encoded, true)
237+
XCTAssertEqual(response.body, Base64.encodeToString(bytes: body.readableBytesView))
238+
}
239+
}
240+
}
241+
242+
func testHeaderValuesURLFunction() async throws {
243+
let router = Router(context: BasicLambdaRequestContext<FunctionURLRequest>.self)
244+
router.middlewares.add(LogRequestsMiddleware(.debug))
245+
router.post { request, _ -> HTTPResponse.Status in
246+
XCTAssertEqual(request.headers[.userAgent], "HBXCT/2.0")
247+
XCTAssertEqual(request.headers[.acceptLanguage], "en")
248+
return .ok
249+
}
250+
router.post("/multi") { request, _ -> HTTPResponse.Status in
251+
XCTAssertEqual(request.headers[.userAgent], "HBXCT/2.0")
252+
XCTAssertEqual(request.headers[values: .acceptLanguage], ["en", "fr"])
253+
return .ok
254+
}
255+
let lambda = FunctionURLLambdaFunction(router: router)
256+
try await lambda.test { client in
257+
try await client.execute(uri: "/", method: .post, headers: [.userAgent: "HBXCT/2.0", .acceptLanguage: "en"]) { response in
258+
XCTAssertEqual(response.statusCode, .ok)
259+
}
260+
var headers: HTTPFields = [.userAgent: "HBXCT/2.0", .acceptLanguage: "en"]
261+
headers[values: .acceptLanguage].append("fr")
262+
try await client.execute(uri: "/multi", method: .post, headers: headers) { response in
263+
XCTAssertEqual(response.statusCode, .ok)
264+
}
265+
}
266+
}
267+
268+
func testQueryValuesURLFunction() async throws {
269+
let router = Router(context: BasicLambdaRequestContext<FunctionURLRequest>.self)
270+
router.middlewares.add(LogRequestsMiddleware(.debug))
271+
router.post { request, _ -> HTTPResponse.Status in
272+
XCTAssertEqual(request.uri.queryParameters["foo"], "bar")
273+
return .ok
274+
}
275+
router.post("/multi") { request, _ -> HTTPResponse.Status in
276+
XCTAssertEqual(request.uri.queryParameters.getAll("foo"), ["bar1", "bar2"])
277+
return .ok
278+
}
279+
let lambda = FunctionURLLambdaFunction(router: router)
280+
try await lambda.test { client in
281+
try await client.execute(uri: "/?foo=bar", method: .post) { response in
282+
XCTAssertEqual(response.statusCode, .ok)
283+
}
284+
try await client.execute(uri: "/multi?foo=bar1&foo=bar2", method: .post) { response in
285+
XCTAssertEqual(response.statusCode, .ok)
286+
}
287+
}
288+
}
289+
208290
func testCustomRequestContext() async throws {
209291
struct MyRequestContext: LambdaRequestContext {
210292
typealias Event = APIGatewayRequest

0 commit comments

Comments
 (0)