diff --git a/Sources/HummingbirdLambda/APIGatewayLambda.swift b/Sources/HummingbirdLambda/APIGatewayLambda.swift index 66e1bbb..4abfbc0 100644 --- a/Sources/HummingbirdLambda/APIGatewayLambda.swift +++ b/Sources/HummingbirdLambda/APIGatewayLambda.swift @@ -36,7 +36,36 @@ extension HBLambda where Output == APIGatewayResponse { } // conform `APIGatewayRequest` to `APIRequest` so we can use HBRequest.init(context:application:from) -extension APIGatewayRequest: APIRequest {} +extension APIGatewayRequest: APIRequest { + var queryString: String { + func urlPercentEncoded(_ string: String) -> String { + return string.addingPercentEncoding(withAllowedCharacters: .urlQueryComponentAllowed) ?? string + } + var queryParams: [String] = [] + var queryStringParameters = self.queryStringParameters ?? [:] + // go through list of multi value query string params first, removing any + // from the single value list if they are found in the multi value list + self.multiValueQueryStringParameters?.forEach { multiValueQuery in + queryStringParameters[multiValueQuery.key] = nil + queryParams += multiValueQuery.value.map { "\(urlPercentEncoded(multiValueQuery.key))=\(urlPercentEncoded($0))" } + } + queryParams += queryStringParameters.map { + "\(urlPercentEncoded($0.key))=\(urlPercentEncoded($0.value))" + } + return queryParams.joined(separator: "&") + } + + var httpHeaders: HTTPHeaders { + var headers = HTTPHeaders(self.headers.map { ($0.key, $0.value) }) + self.multiValueHeaders.forEach { multiValueHeader in + headers.remove(name: multiValueHeader.key) + for header in multiValueHeader.value { + headers.add(name: multiValueHeader.key, value: header) + } + } + return headers + } +} // conform `APIGatewayResponse` to `APIResponse` so we can use HBResponse.apiReponse() extension APIGatewayResponse: APIResponse {} diff --git a/Sources/HummingbirdLambda/APIGatewayV2Lambda.swift b/Sources/HummingbirdLambda/APIGatewayV2Lambda.swift index 5d62a39..34df5b0 100644 --- a/Sources/HummingbirdLambda/APIGatewayV2Lambda.swift +++ b/Sources/HummingbirdLambda/APIGatewayV2Lambda.swift @@ -42,8 +42,10 @@ extension APIGatewayV2Request: APIRequest { } var httpMethod: AWSLambdaEvents.HTTPMethod { context.http.method } - var multiValueQueryStringParameters: [String: [String]]? { nil } - var multiValueHeaders: HTTPMultiValueHeaders { [:] } + var queryString: String { self.rawQueryString } + var httpHeaders: HTTPHeaders { + return HTTPHeaders(self.headers.map { ($0.key, $0.value) }) + } } // conform `APIGatewayV2Response` to `APIResponse` so we can use HBResponse.apiReponse() diff --git a/Sources/HummingbirdLambda/Request+APIGateway.swift b/Sources/HummingbirdLambda/Request+APIGateway.swift index a9f8efc..6243150 100644 --- a/Sources/HummingbirdLambda/Request+APIGateway.swift +++ b/Sources/HummingbirdLambda/Request+APIGateway.swift @@ -22,10 +22,8 @@ import NIOHTTP1 protocol APIRequest { var path: String { get } var httpMethod: AWSLambdaEvents.HTTPMethod { get } - var queryStringParameters: [String: String]? { get } - var multiValueQueryStringParameters: [String: [String]]? { get } - var headers: AWSLambdaEvents.HTTPHeaders { get } - var multiValueHeaders: HTTPMultiValueHeaders { get } + var queryString: String { get } + var httpHeaders: HTTPHeaders { get } var body: String? { get } var isBase64Encoded: Bool { get } } @@ -42,28 +40,11 @@ extension HBRequest { } // construct URI with query parameters var uri = from.path - var queryParams: [String] = [] - var queryStringParameters = from.queryStringParameters ?? [:] - // go through list of multi value query string params first, removing any - // from the single value list if they are found in the multi value list - from.multiValueQueryStringParameters?.forEach { multiValueQuery in - queryStringParameters[multiValueQuery.key] = nil - queryParams += multiValueQuery.value.map { "\(urlPercentEncoded(multiValueQuery.key))=\(urlPercentEncoded($0))" } - } - queryParams += queryStringParameters.map { - "\(urlPercentEncoded($0.key))=\(urlPercentEncoded($0.value))" - } - if queryParams.count > 0 { - uri += "?\(queryParams.joined(separator: "&"))" + if from.queryString.count > 0 { + uri += "?\(from.queryString)" } // construct headers - var headers = NIOHTTP1.HTTPHeaders(from.headers.map { ($0.key, $0.value) }) - from.multiValueHeaders.forEach { multiValueHeader in - headers.remove(name: multiValueHeader.key) - for header in multiValueHeader.value { - headers.add(name: multiValueHeader.key, value: header) - } - } + let headers = from.httpHeaders let head = HTTPRequestHead( version: .init(major: 2, minor: 0), method: .init(rawValue: from.httpMethod.rawValue), diff --git a/Tests/HummingbirdLambdaTests/LambdaTests.swift b/Tests/HummingbirdLambdaTests/LambdaTests.swift index 3b12d2f..84a3429 100644 --- a/Tests/HummingbirdLambdaTests/LambdaTests.swift +++ b/Tests/HummingbirdLambdaTests/LambdaTests.swift @@ -55,15 +55,23 @@ final class LambdaTests: XCTestCase { ) } - func newEvent(uri: String, method: String, body: ByteBuffer? = nil) throws -> APIGatewayRequest { + func newEvent( + uri: String, + method: String, + queryValues: [String: String]? = nil, + multiQueryValues: [String: [String]]? = nil, + body: ByteBuffer? = nil + ) throws -> APIGatewayRequest { let base64Body = body.map { "\"\(String(base64Encoding: $0.readableBytesView))\"" } ?? "null" + let queryValuesString = try queryValues.map { try String(decoding: JSONEncoder().encode($0), as: UTF8.self) } ?? "null" + let multiQueryValuesString = try multiQueryValues.map { try String(decoding: JSONEncoder().encode($0), as: UTF8.self) } ?? "null" let request = """ - {"httpMethod": "\(method)", "body": \(base64Body), "resource": "\(uri)", "requestContext": {"resourceId": "123456", "apiId": "1234567890", "resourcePath": "\(uri)", "httpMethod": "\(method)", "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", "accountId": "123456789012", "stage": "Prod", "identity": {"apiKey": null, "userArn": null, "cognitoAuthenticationType": null, "caller": null, "userAgent": "Custom User Agent String", "user": null, "cognitoIdentityPoolId": null, "cognitoAuthenticationProvider": null, "sourceIp": "127.0.0.1", "accountId": null}, "extendedRequestId": null, "path": "\(uri)"}, "queryStringParameters": null, "multiValueQueryStringParameters": null, "headers": {"Host": "127.0.0.1:3000", "Connection": "keep-alive", "Cache-Control": "max-age=0", "Dnt": "1", "Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.87 Safari/537.36 Edg/78.0.276.24", "Sec-Fetch-User": "?1", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3", "Sec-Fetch-Site": "none", "Sec-Fetch-Mode": "navigate", "Accept-Encoding": "gzip, deflate, br", "Accept-Language": "en-US,en;q=0.9", "X-Forwarded-Proto": "http", "X-Forwarded-Port": "3000"}, "multiValueHeaders": {"Host": ["127.0.0.1:3000"], "Connection": ["keep-alive"], "Cache-Control": ["max-age=0"], "Dnt": ["1"], "Upgrade-Insecure-Requests": ["1"], "User-Agent": ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.87 Safari/537.36 Edg/78.0.276.24"], "Sec-Fetch-User": ["?1"], "Accept": ["text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"], "Sec-Fetch-Site": ["none"], "Sec-Fetch-Mode": ["navigate"], "Accept-Encoding": ["gzip, deflate, br"], "Accept-Language": ["en-US,en;q=0.9"], "X-Forwarded-Proto": ["http"], "X-Forwarded-Port": ["3000"]}, "pathParameters": null, "stageVariables": null, "path": "\(uri)", "isBase64Encoded": \(body != nil)} + {"httpMethod": "\(method)", "body": \(base64Body), "resource": "\(uri)", "requestContext": {"resourceId": "123456", "apiId": "1234567890", "resourcePath": "\(uri)", "httpMethod": "\(method)", "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", "accountId": "123456789012", "stage": "Prod", "identity": {"apiKey": null, "userArn": null, "cognitoAuthenticationType": null, "caller": null, "userAgent": "Custom User Agent String", "user": null, "cognitoIdentityPoolId": null, "cognitoAuthenticationProvider": null, "sourceIp": "127.0.0.1", "accountId": null}, "extendedRequestId": null, "path": "\(uri)"}, "queryStringParameters": \(queryValuesString), "multiValueQueryStringParameters": \(multiQueryValuesString), "headers": {"Host": "127.0.0.1:3000", "Connection": "keep-alive", "Cache-Control": "max-age=0", "Dnt": "1", "Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.87 Safari/537.36 Edg/78.0.276.24", "Sec-Fetch-User": "?1", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3", "Sec-Fetch-Site": "none", "Sec-Fetch-Mode": "navigate", "Accept-Encoding": "gzip, deflate, br", "Accept-Language": "en-US,en;q=0.9", "X-Forwarded-Proto": "http", "X-Forwarded-Port": "3000"}, "multiValueHeaders": {"Host": ["127.0.0.1:3000"], "Connection": ["keep-alive"], "Cache-Control": ["max-age=0"], "Dnt": ["1"], "Upgrade-Insecure-Requests": ["1"], "User-Agent": ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.87 Safari/537.36 Edg/78.0.276.24"], "Sec-Fetch-User": ["?1"], "Accept": ["text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"], "Sec-Fetch-Site": ["none"], "Sec-Fetch-Mode": ["navigate"], "Accept-Encoding": ["gzip, deflate, br"], "Accept-Language": ["en-US,en;q=0.9"], "X-Forwarded-Proto": ["http"], "X-Forwarded-Port": ["3000"]}, "pathParameters": null, "stageVariables": null, "path": "\(uri)", "isBase64Encoded": \(body != nil)} """ return try JSONDecoder().decode(APIGatewayRequest.self, from: Data(request.utf8)) } - func newV2Event(uri: String, method: String) throws -> APIGatewayV2Request { + func newV2Event(uri: String, method: String, rawQueryString: String = "") throws -> APIGatewayV2Request { let request = """ { "routeKey":"\(method) \(uri)", @@ -103,7 +111,7 @@ final class LambdaTests: XCTestCase { "time":"24/Apr/2020:17:47:41 +0000" }, "isBase64Encoded":false, - "rawQueryString":"foo=bar", + "rawQueryString":"\(rawQueryString)", "queryStringParameters":{ "foo":"bar" }, @@ -174,14 +182,17 @@ final class LambdaTests: XCTestCase { init(_ app: HBApplication) { app.middleware.add(HBLogRequestsMiddleware(.debug)) - app.router.post { _ in + app.router.post("test") { request in + XCTAssertEqual(request.method, .POST) + XCTAssertEqual(request.uri.path, "/test") + XCTAssertNil(request.uri.query) return "hello" } } } let lambda = try HBLambdaHandler.makeHandler(context: self.initializationContext).wait() let context = self.newContext() - let event = try newV2Event(uri: "/", method: "POST") + let event = try newV2Event(uri: "/test", method: "POST") let response = try lambda.handle(event, context: context).wait() XCTAssertEqual(response.statusCode, .ok) XCTAssertEqual(response.body, "hello") @@ -207,4 +218,64 @@ final class LambdaTests: XCTestCase { XCTAssertEqual(response.statusCode, .badRequest) XCTAssertEqual(response.body, "BadRequest") } + + func testAPIGatewayQueryValues() throws { + struct HelloLambda: HBLambda { + // define input and output + typealias Event = APIGatewayRequest + typealias Output = APIGatewayResponse + + init(_ app: HBApplication) { + app.middleware.add(HBLogRequestsMiddleware(.debug)) + app.router.post { request -> HTTPResponseStatus in + XCTAssertEqual(request.uri.queryParameters["foo"], "bar") + return .ok + } + } + } + let lambda = try HBLambdaHandler.makeHandler(context: self.initializationContext).wait() + let context = self.newContext() + let event = try newEvent(uri: "/", method: "POST", queryValues: ["foo": "bar"]) + _ = try lambda.handle(event, context: context).wait() + } + + func testAPIGatewayMultiQueryValues() throws { + struct HelloLambda: HBLambda { + // define input and output + typealias Event = APIGatewayRequest + typealias Output = APIGatewayResponse + + init(_ app: HBApplication) { + app.middleware.add(HBLogRequestsMiddleware(.debug)) + app.router.post { request -> HTTPResponseStatus in + XCTAssertEqual(request.uri.queryParameters.getAll("foo"), ["bar1", "bar2"]) + return .ok + } + } + } + let lambda = try HBLambdaHandler.makeHandler(context: self.initializationContext).wait() + let context = self.newContext() + let event = try newEvent(uri: "/", method: "POST", queryValues: ["foo": "bar"], multiQueryValues: ["foo": ["bar1", "bar2"]]) + _ = try lambda.handle(event, context: context).wait() + } + + func testAPIGateway2QueryValues() throws { + struct HelloLambda: HBLambda { + // define input and output + typealias Event = APIGatewayV2Request + typealias Output = APIGatewayV2Response + + init(_ app: HBApplication) { + app.middleware.add(HBLogRequestsMiddleware(.debug)) + app.router.post { request -> HTTPResponseStatus in + XCTAssertEqual(request.uri.queryParameters["foo"], "bar") + return .ok + } + } + } + let lambda = try HBLambdaHandler.makeHandler(context: self.initializationContext).wait() + let context = self.newContext() + let event = try newV2Event(uri: "/", method: "POST", rawQueryString: "foo=bar") + _ = try lambda.handle(event, context: context).wait() + } }