From ef2e29fc2685b86ebbd9fb7448391e83a671158a Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Wed, 13 Dec 2023 16:46:45 -0500 Subject: [PATCH] simplify code for API Gateway V2 (HTTP API) --- README.md | 144 +++++++++++++----- .../APIGatewayV2+HTTPRequest.swift | 30 ++-- .../OpenAPILambdaHttpApi.swift} | 14 +- Sources/OpenAPILambda.swift | 1 - Sources/Router/OpenAPILambdaRouter.swift | 1 - 5 files changed, 129 insertions(+), 61 deletions(-) rename Sources/{ => HttpApi}/APIGatewayV2+HTTPRequest.swift (85%) rename Sources/{OpenAPILambda+APIGatewayV2.swift => HttpApi/OpenAPILambdaHttpApi.swift} (67%) diff --git a/README.md b/README.md index e8cc884..b8621ac 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,15 @@ -# swift-openapi-lambda +# AWS Lambda transport for Swift OpenAPI -An [AWS Lambda](https://docs.aws.amazon.com/lambda/latest/dg/welcome.html) Transport for [Swift OpenAPI generator](https://github.com/apple/swift-openapi-generator) +This library provides an [AWS Lambda](https://docs.aws.amazon.com/lambda/latest/dg/welcome.html) transport for [Swift OpenAPI generator](https://github.com/apple/swift-openapi-generator) -This library allows to expose server side Swift OpenAPI implementation generated by the Swift OpenAPI generator as an AWS Lambda function and an AWS API Gateway. +This library allows to expose server side Swift OpenAPI implementation generated by the Swift OpenAPI generator as an AWS Lambda function. + +The library provides two capabilities: + +- a default implementation of an AWS Lambda function in that consumes your OpenAPI service implementation +- a binding with the [Amazon API Gateway (HTTP API mode)](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html) (aka `APIGatewayV2`) event type. + +Other Lambda function bindings (event types) are supported as well, depending on your needs. [We include instructions](#implement-your-own-openapilambda-to-support-other-event-types) to create a binding with an [Amazon API Gateway (REST API mode)](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.html) ## Prerequisites @@ -15,12 +22,16 @@ To write and deploy AWS Lambda functions based on an OpenAPI API definition, you ## TL;DR -Assuming you already have an OpenAPI definition and you already generated the server stubs. Here are the additional steps to expose your service implementation as a AWS Lambda function. +If you already have an OpenAPI definition, you already generated the server stubs, and wrote an implementation, here are the additional steps to expose your OpenAPI service implementation as a AWS Lambda function and an Amazon API Gateway HTTP API (aka `APIGatewayV2`). -If you don't know how to do that, read on, there is [a tutorial with step-by-step instructions](#tutorial-a-quick-start-with-a-stock-quote-api-service-example). +If you don't know how to start, read the next section, there is [a tutorial with step-by-step instructions](#tutorial-a-quick-start-with-a-stock-quote-api-service-example). + +To expose your OpenAPI implementation as an AWS Lambda function: 1. Add the dependency to your `Package.swift` + The project dependencies: + ```swift dependencies: [ .package(url: "https://github.com/apple/swift-openapi-generator.git", .upToNextMinor(from: "1.0.0-alpha.1")), @@ -31,6 +42,8 @@ If you don't know how to do that, read on, there is [a tutorial with step-by-ste ], ``` + The target dependencies: + ```swift .executableTarget( name: "YourOpenAPIService", @@ -42,23 +55,38 @@ If you don't know how to do that, read on, there is [a tutorial with step-by-ste ], ``` -2. Create a AWS Lambda function that consumes your OpenAPI implementation +2. Add a protocol and a constructor to your existing OpenAPI service implementation ```swift -import AWSLambdaEvents -import OpenAPILambda +import Foundation +import OpenAPIRuntime +import OpenAPILambda // <-- add this line @main -struct QuoteServiceLambda: OpenAPILambda { - - typealias Event = APIGatewayV2Request - typealias Output = APIGatewayV2Response +struct QuoteServiceImpl: APIProtocol, OpenAPILambdaHttpApi { // <-- add the OpenAPILambdaHttpApi protocol - public init(transport: LambdaOpenAPITransport) throws { - let openAPIHandler = QuoteServiceImpl() - try openAPIHandler.registerHandlers(on: transport) + init(transport: LambdaOpenAPITransport) throws { // <-- add this constructor (don't remove the call to `registerHandlers(on:)`) + try self.registerHandlers(on: transport) + } + + // the rest below is unmodified + + func getQuote(_ input: Operations.getQuote.Input) async throws -> Operations.getQuote.Output { + + let symbol = input.path.symbol + + let price = Components.Schemas.quote( + symbol: symbol, + price: Double.random(in: 100..<150).rounded(), + change: Double.random(in: -5..<5).rounded(), + changePercent: Double.random(in: -0.05..<0.05), + volume: Double.random(in: 10000..<100000).rounded(), + timestamp: Date()) + + return .ok(.init(body: .json(price))) } } + ``` 3. Package and deploy your Lambda function + create an HTTP API Gateway (aka `APIGatewayV2`) @@ -67,7 +95,7 @@ struct QuoteServiceLambda: OpenAPILambda { ## Tutorial (a Quick Start with a Stock Quote API service example) -### Part 1 - the Swift OpenAPI part +### Part 1 - the code 1. Create a Swift executable project @@ -204,13 +232,19 @@ rm Sources/main.swift cat << EOF > Sources/QuoteService.swift import Foundation import OpenAPIRuntime +import OpenAPILambda + +@main +struct QuoteServiceImpl: APIProtocol, OpenAPILambdaHttpApi { + + init(transport: LambdaOpenAPITransport) throws { + try self.registerHandlers(on: transport) + } -struct QuoteServiceImpl: APIProtocol { func getQuote(_ input: Operations.getQuote.Input) async throws -> Operations.getQuote.Output { let symbol = input.path.symbol - // in real life, don't use random values 🤣 let price = Components.Schemas.quote( symbol: symbol, price: Double.random(in: 100..<150).rounded(), @@ -225,30 +259,15 @@ struct QuoteServiceImpl: APIProtocol { EOF ``` -### Part 2 - the Lambda part - - -1. Add a Lambda function that consumes your OpenAPI service implementation. The Lambda is invoked by an AWS API Gateway. +7. Build the project to ensure everything works ```sh -cat << EOF > Sources/Lambda.swift -import AWSLambdaEvents -import OpenAPILambda - -@main -struct QuoteServiceLambda: OpenAPILambda { - - typealias Event = APIGatewayV2Request - typealias Output = APIGatewayV2Response - - public init(transport: LambdaOpenAPITransport) throws { - let openAPIHandler = QuoteServiceImpl() - try openAPIHandler.registerHandlers(on: transport) - } -} +swift build ``` -2. Add the build instructions as a Docker file and a Makefile. We build for Swift 5.9 on Amazon Linux 2 +### Part 2 - the deployment + +1. Add the Lambda build instructions as a Docker file and a Makefile. We build for Swift 5.9 on Amazon Linux 2 ```sh cat << EOF > Dockerfile @@ -299,16 +318,55 @@ builder-bot: cp $($@STAGE)/* $($@ARTIFACTS_DIR) EOF ``` -3. Build the executable + +2. Add a SAM template to deploy the Lambda function and the API Gateway + +```sh +cat << EOF > template.yml +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: SAM Template for QuoteService + +Globals: + Function: + Timeout: 60 + CodeUri: . + Handler: swift.bootstrap + Runtime: provided.al2 + MemorySize: 512 + Architectures: + - arm64 + +Resources: + # Lambda function + QuoteService: + Type: AWS::Serverless::Function + Properties: + Events: + # handles all GET / method of the REST API + Api: + Type: HttpApi + Metadata: + BuildMethod: makefile + +# print API endpoint and name of database table +Outputs: + SwiftAPIEndpoint: + Description: "API Gateway endpoint URL for your application" + Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com" +EOF +``` + +3. Build the Lambda function executable for Amazon Linux 2 ```sh sam build ``` -4. Deploy the Lambda function and creates an API Gateway in front of it +4. Deploy the Lambda function and create an API Gateway in front of it ```sh -# use --guided for the first deployment. SAM cli collects a few parameters and store them in `samconfig.toml` +# use --guided for the first deployment only. SAM cli collects a few parameters and store them in `samconfig.toml` sam deploy --guided ``` @@ -348,6 +406,10 @@ LOCAL_LAMBDA_SERVER_ENABLED=true swift run curl -v -X POST --header "Content-Type: application/json" --data @events/GetQuote.json http://127.0.0.1:7000/invoke ``` +## Implement your own `OpenAPILambda` to support other event types + +TBD + ## References ### Swift OpenAPI generator diff --git a/Sources/APIGatewayV2+HTTPRequest.swift b/Sources/HttpApi/APIGatewayV2+HTTPRequest.swift similarity index 85% rename from Sources/APIGatewayV2+HTTPRequest.swift rename to Sources/HttpApi/APIGatewayV2+HTTPRequest.swift index 398b91b..fa1b6da 100644 --- a/Sources/APIGatewayV2+HTTPRequest.swift +++ b/Sources/HttpApi/APIGatewayV2+HTTPRequest.swift @@ -1,27 +1,13 @@ -import Foundation import AWSLambdaEvents import HTTPTypes import OpenAPIRuntime -extension HTTPHeaders { - func httpFields() -> HTTPFields { - HTTPFields(self.map { key, value in HTTPField(name: .init(key)!, value: value) }) - } - - /// Create HTTPHeaders from HTTPFields - public init(from fields: HTTPFields) { - var headers: HTTPHeaders = [:] - fields.forEach { headers[$0.name.rawName] = $0.value } - self = headers - } -} - extension APIGatewayV2Request { /// Return an `HTTPRequest.Method` for this `APIGatewayV2Request` public func httpRequestMethod() throws -> HTTPRequest.Method { guard let method = HTTPRequest.Method(rawValue: self.context.http.method.rawValue) else { - throw LambdaOpenAPIRouterError.invalidMethod(self.context.http.method.rawValue) + throw LambdaOpenAPIHttpError.invalidMethod(self.context.http.method.rawValue) } return method } @@ -50,3 +36,17 @@ extension APIGatewayV2Response { ) } } + +public extension HTTPHeaders { + /// Create an `HTTPFields` (from `HTTPTypes` library) from this APIGateway `HTTPHeader` + func httpFields() -> HTTPFields { + HTTPFields(self.map { key, value in HTTPField(name: .init(key)!, value: value) }) + } + + /// Create HTTPHeaders from HTTPFields + init(from fields: HTTPFields) { + var headers: HTTPHeaders = [:] + fields.forEach { headers[$0.name.rawName] = $0.value } + self = headers + } +} diff --git a/Sources/OpenAPILambda+APIGatewayV2.swift b/Sources/HttpApi/OpenAPILambdaHttpApi.swift similarity index 67% rename from Sources/OpenAPILambda+APIGatewayV2.swift rename to Sources/HttpApi/OpenAPILambdaHttpApi.swift index 919e320..bf948b0 100644 --- a/Sources/OpenAPILambda+APIGatewayV2.swift +++ b/Sources/HttpApi/OpenAPILambdaHttpApi.swift @@ -1,15 +1,23 @@ +import Foundation import AWSLambdaRuntime import AWSLambdaEvents +import OpenAPIRuntime import HTTPTypes -extension OpenAPILambda where Event == APIGatewayV2Request { +public enum LambdaOpenAPIHttpError: Error { + case invalidMethod(String) +} + +public protocol OpenAPILambdaHttpApi: OpenAPILambda where Event == APIGatewayV2Request, + Output == APIGatewayV2Response {} + + +extension OpenAPILambdaHttpApi { /// Transform a Lambda input (`APIGatewayV2Request` and `LambdaContext`) to an OpenAPILambdaRequest (`HTTPRequest`, `String?`) public func request(context: LambdaContext, from request: Event) throws -> OpenAPILambdaRequest { (try request.httpRequest(), request.body) } -} -extension OpenAPILambda where Output == APIGatewayV2Response { /// Transform an OpenAPI response (`HTTPResponse`, `String?`) to a Lambda Output (`APIGatewayV2Response`) public func output(from response: OpenAPILambdaResponse) -> Output { var apiResponse = APIGatewayV2Response(from: response.0) diff --git a/Sources/OpenAPILambda.swift b/Sources/OpenAPILambda.swift index 7ff4bb6..66125f4 100644 --- a/Sources/OpenAPILambda.swift +++ b/Sources/OpenAPILambda.swift @@ -1,6 +1,5 @@ import Foundation import AWSLambdaRuntime -import AWSLambdaEvents import OpenAPIRuntime import HTTPTypes diff --git a/Sources/Router/OpenAPILambdaRouter.swift b/Sources/Router/OpenAPILambdaRouter.swift index b5bf867..6f62c53 100644 --- a/Sources/Router/OpenAPILambdaRouter.swift +++ b/Sources/Router/OpenAPILambdaRouter.swift @@ -7,7 +7,6 @@ public enum LambdaOpenAPIRouterError: Error { case noRouteForPath(String) case noHandlerForPath(String) case noRouteForMethod(HTTPRequest.Method) - case invalidMethod(String) } /// A router API