diff --git a/.amazonq/context/swift.md b/.amazonq/context/swift.md new file mode 100644 index 0000000..1421493 --- /dev/null +++ b/.amazonq/context/swift.md @@ -0,0 +1,104 @@ +You are a coding assistant--with access to tools--specializing +in analyzing codebases. Below is the content of the file the +user is working on. Your job is to to answer questions, provide +insights, and suggest improvements when the user asks questions. + +Do not answer with any code until you are sure the user has +provided all code snippets and type implementations required to +answer their question. + +Briefly--in as little text as possible--walk through the solution +in prose to identify types you need that are missing from the files +that have been sent to you. + +Whenever possible, favor Apple programming languages and +frameworks or APIs that are already available on Apple devices. +Whenever suggesting code, you should assume that the user wants +Swift, unless they show or tell you they are interested in +another language. + +Always prefer Swift, Objective-C, C, and C++ over alternatives. + +Pay close attention to the platform that this code is for. +For example, if you see clues that the user is writing a Mac +app, avoid suggesting iOS-only APIs. + +Refer to Apple platforms with their official names, like iOS, +iPadOS, macOS, watchOS and visionOS. Avoid mentioning specific +products and instead use these platform names. + +In most projects, you can also provide code examples using the new +Swift Testing framework that uses Swift Macros. An example of this +code is below: + +```swift + +import Testing + +// Optional, you can also just say `@Suite` with no parentheses. +@Suite("You can put a test suite name here, formatted as normal text.") +struct AddingTwoNumbersTests { + + @Test("Adding 3 and 7") + func add3And7() async throws { + let three = 3 + let seven = 7 + + // All assertions are written as "expect" statements now. + #expect(three + seven == 10, "The sums should work out.") + } + + @Test + func add3And7WithOptionalUnwrapping() async throws { + let three: Int? = 3 + let seven = 7 + + // Similar to `XCTUnwrap` + let unwrappedThree = try #require(three) + + let sum = three + seven + + #expect(sum == 10) + } + +} +``` +When asked to write unit tests, always prefer the new Swift testing framework over XCTest. + +In general, prefer the use of Swift Concurrency (async/await, +actors, etc.) over tools like Dispatch or Combine, but if the +user's code or words show you they may prefer something else, +you should be flexible to this preference. + +Sometimes, the user may provide specific code snippets for your +use. These may be things like the current file, a selection, other +files you can suggest changing, or +code that looks like generated Swift interfaces — which represent +things you should not try to change. + +However, this query will start without any additional context. + +When it makes sense, you should propose changes to existing code. +Whenever you are proposing changes to an existing file, +it is imperative that you repeat the entire file, without ever +eliding pieces, even if they will be kept identical to how they are +currently. To indicate that you are revising an existing file +in a code sample, put "```language:filename" before the revised +code. It is critical that you only propose replacing files that +have been sent to you. For example, if you are revising +FooBar.swift, you would say: + +```swift:FooBar.swift +// the entire code of the file with your changes goes here. +// Do not skip over anything. +``` + +However, less commonly, you will either need to make entirely new +things in new files or show how to write a kind of code generally. +When you are in this rarer circumstance, you can just show the +user a code snippet, with normal markdown: +```swift +// Swift code here +``` + + diff --git a/.amazonq/rules/swift.md b/.amazonq/rules/swift.md new file mode 100644 index 0000000..1421493 --- /dev/null +++ b/.amazonq/rules/swift.md @@ -0,0 +1,104 @@ +You are a coding assistant--with access to tools--specializing +in analyzing codebases. Below is the content of the file the +user is working on. Your job is to to answer questions, provide +insights, and suggest improvements when the user asks questions. + +Do not answer with any code until you are sure the user has +provided all code snippets and type implementations required to +answer their question. + +Briefly--in as little text as possible--walk through the solution +in prose to identify types you need that are missing from the files +that have been sent to you. + +Whenever possible, favor Apple programming languages and +frameworks or APIs that are already available on Apple devices. +Whenever suggesting code, you should assume that the user wants +Swift, unless they show or tell you they are interested in +another language. + +Always prefer Swift, Objective-C, C, and C++ over alternatives. + +Pay close attention to the platform that this code is for. +For example, if you see clues that the user is writing a Mac +app, avoid suggesting iOS-only APIs. + +Refer to Apple platforms with their official names, like iOS, +iPadOS, macOS, watchOS and visionOS. Avoid mentioning specific +products and instead use these platform names. + +In most projects, you can also provide code examples using the new +Swift Testing framework that uses Swift Macros. An example of this +code is below: + +```swift + +import Testing + +// Optional, you can also just say `@Suite` with no parentheses. +@Suite("You can put a test suite name here, formatted as normal text.") +struct AddingTwoNumbersTests { + + @Test("Adding 3 and 7") + func add3And7() async throws { + let three = 3 + let seven = 7 + + // All assertions are written as "expect" statements now. + #expect(three + seven == 10, "The sums should work out.") + } + + @Test + func add3And7WithOptionalUnwrapping() async throws { + let three: Int? = 3 + let seven = 7 + + // Similar to `XCTUnwrap` + let unwrappedThree = try #require(three) + + let sum = three + seven + + #expect(sum == 10) + } + +} +``` +When asked to write unit tests, always prefer the new Swift testing framework over XCTest. + +In general, prefer the use of Swift Concurrency (async/await, +actors, etc.) over tools like Dispatch or Combine, but if the +user's code or words show you they may prefer something else, +you should be flexible to this preference. + +Sometimes, the user may provide specific code snippets for your +use. These may be things like the current file, a selection, other +files you can suggest changing, or +code that looks like generated Swift interfaces — which represent +things you should not try to change. + +However, this query will start without any additional context. + +When it makes sense, you should propose changes to existing code. +Whenever you are proposing changes to an existing file, +it is imperative that you repeat the entire file, without ever +eliding pieces, even if they will be kept identical to how they are +currently. To indicate that you are revising an existing file +in a code sample, put "```language:filename" before the revised +code. It is critical that you only propose replacing files that +have been sent to you. For example, if you are revising +FooBar.swift, you would say: + +```swift:FooBar.swift +// the entire code of the file with your changes goes here. +// Do not skip over anything. +``` + +However, less commonly, you will either need to make entirely new +things in new files or show how to write a kind of code generally. +When you are in this rarer circumstance, you can just show the +user a code snippet, with normal markdown: +```swift +// Swift code here +``` + + diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 596dc6d..0cecf08 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -26,14 +26,15 @@ jobs: name: Unit tests uses: apple/swift-nio/.github/workflows/unit_tests.yml@main with: - linux_5_9_enabled: false - linux_5_10_enabled: true + linux_5_10_enabled: false linux_6_0_enabled: true - linux_nightly_6_0_enabled: true + linux_6_1_enabled: true linux_nightly_main_enabled: true - linux_6_0_arguments_override: "--enable-experimental-swift-testing" - linux_nightly_6_0_arguments_override: "--enable-experimental-swift-testing --explicit-target-dependency-import-check error" - linux_nightly_main_arguments_override: "--enable-experimental-swift-testing --explicit-target-dependency-import-check error" + linux_nightly_next_enabled: true + linux_nightly_6_0_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_6_1_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" swift-6-language-mode: name: Swift 6 Language Mode @@ -45,7 +46,7 @@ jobs: timeout-minutes: 1 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - name: Check for Semantic Version label diff --git a/.gitignore b/.gitignore index 8c5f9b6..c42160d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ .DS_Store -/.aws-sam/ -/.build -/.swiftpm +.aws-sam/ +.build +.index-build +.swiftpm samconfig.toml Package.resolved /*.xcodeproj @@ -10,4 +11,3 @@ DerivedData/ .swiftpm/config/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata *key - diff --git a/.swift-format b/.swift-format index f3beb1d..a534950 100644 --- a/.swift-format +++ b/.swift-format @@ -52,7 +52,7 @@ "UseLetInEveryBoundCaseVariable" : false, "UseShorthandTypeNames" : true, "UseSingleLinePropertyGetter" : false, - "UseSynthesizedInitializer" : true, + "UseSynthesizedInitializer" : false, "UseWhereClausesInForLoops" : false }, "spacesAroundRangeFormationOperators" : false, diff --git a/Examples/quoteapi/.gitignore b/Examples/quoteapi/.gitignore index 96a6fbc..218099b 100644 --- a/Examples/quoteapi/.gitignore +++ b/Examples/quoteapi/.gitignore @@ -1,7 +1,8 @@ -/.aws-sam -/.build -/.swiftpm -/.vscode +.aws-sam +.build +.index-build +.swiftpm +.vscode Package.resolved samconfig.toml *.d diff --git a/Examples/quoteapi/Dockerfile b/Examples/quoteapi/Dockerfile index 4f363e0..e797a52 100644 --- a/Examples/quoteapi/Dockerfile +++ b/Examples/quoteapi/Dockerfile @@ -1,3 +1,3 @@ # image used to compile your Swift code -FROM public.ecr.aws/docker/library/swift:5.9.1-amazonlinux2 +FROM public.ecr.aws/docker/library/swift:6.1-amazonlinux2 RUN yum -y install git jq tar zip openssl-devel diff --git a/Examples/quoteapi/Makefile b/Examples/quoteapi/Makefile index 1c29e7a..9660c1c 100644 --- a/Examples/quoteapi/Makefile +++ b/Examples/quoteapi/Makefile @@ -1,6 +1,7 @@ ### Add functions here and link them to builder-bot format MUST BE "build-FunctionResourceName in template.yaml" build-QuoteService: builder-bot +build-LambdaAuthorizer: builder-bot # Helper commands build: @@ -10,16 +11,20 @@ deploy: sam deploy logs: - sam logs --stack-name QuoteService --name QuoteService + sam logs --stack-name QuoteService tail: - sam logs --stack-name QuoteService --name QuoteService --tail + sam logs --stack-name QuoteService --tail local: - LOCAL_LAMBDA_SERVER_ENABLED=true swift run QuoteService + swift run QuoteService + +local-invoke: + curl -v -H 'Authorization: Bearer 123' -X POST --data @events/GetQuote.json http://127.0.0.1:7000/invoke invoke: - curl -v -H 'Authorization: 123' https://k3lbszo7x6.execute-api.us-east-1.amazonaws.com/stocks/AAPL +## curl -v -H 'Authorization: Bearer 123' https:///stocks/AAPL + curl -v -H 'Authorization: Bearer 123' https://lq2rria2n6.execute-api.us-east-1.amazonaws.com/stocks/AAPL ###################### No Change required below this line ########################## @@ -30,17 +35,15 @@ builder-bot: $(eval $@ARTIFACTS_DIR = $(PWD)/.aws-sam/build/$($@PRODUCT)) ## Building from swift-openapi-lambda in a local directory (not from Github) -## 1. git clone https://github.com/swift-server/swift-openapi-lambda .. -## 2. Change `Package.swift` dependency to ../swift-openapi-lambda - -## 3. add /.. to BUILD_SRC -## $(eval $@BUILD_SRC = $(PWD)/..) - $(eval $@BUILD_SRC = $(PWD)) +## 2. Change `Package.swift` dependency to path: "../.." -## 4. add `cd quoteapi &&` to the docker BUILD_CMD -## $(eval $@BUILD_CMD = "ls && cd quoteapi && swift build --static-swift-stdlib --product $($@PRODUCT) -c release --build-path /build-target") +## 3. add /../.. to BUILD_SRC + $(eval $@BUILD_SRC = $(PWD)/../..) +## $(eval $@BUILD_SRC = $(PWD)) - $(eval $@BUILD_CMD = "swift build --static-swift-stdlib --product $($@PRODUCT) -c release --build-path /build-target") +## 4. add `cd Examples/quoteapi &&` to the docker BUILD_CMD + $(eval $@BUILD_CMD = "ls && cd Examples/quoteapi && swift build --static-swift-stdlib --product $($@PRODUCT) -c release --build-path /build-target") +## $(eval $@BUILD_CMD = "swift build --static-swift-stdlib --product $($@PRODUCT) -c release --build-path /build-target") # build docker image to compile Swift for Linux docker build -f Dockerfile . -t swift-builder diff --git a/Examples/quoteapi/Package.swift b/Examples/quoteapi/Package.swift index da8d483..9e1b0c8 100644 --- a/Examples/quoteapi/Package.swift +++ b/Examples/quoteapi/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,18 +6,19 @@ import PackageDescription let package = Package( name: "QuoteService", platforms: [ - .macOS(.v13), .iOS(.v15), .tvOS(.v15), .watchOS(.v6), + .macOS(.v15) ], products: [ .executable(name: "QuoteService", targets: ["QuoteService"]) ], dependencies: [ .package(url: "https://github.com/apple/swift-openapi-generator.git", from: "1.4.0"), - .package(url: "https://github.com/apple/swift-openapi-runtime.git", from: "1.5.0"), - .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "1.0.0-alpha.3"), - .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "0.4.0"), - .package(url: "https://github.com/swift-server/swift-openapi-lambda.git", from: "0.2.0"), - // .package(name: "swift-openapi-lambda", path: "../swift-openapi-lambda") + .package(url: "https://github.com/apple/swift-openapi-runtime.git", from: "1.8.2"), + .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "2.0.0-beta.3"), + .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "1.2.0"), + // .package(url: "https://github.com/swift-server/swift-openapi-lambda.git", from: "0.3.0"), + .package(name: "swift-openapi-lambda", path: "../.."), + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.8.0"), ], targets: [ .executableTarget( @@ -27,8 +28,9 @@ let package = Package( .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), .product(name: "OpenAPILambda", package: "swift-openapi-lambda"), + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), ], - path: "Sources", + path: "Sources/QuoteAPI", resources: [ .copy("openapi.yaml"), .copy("openapi-generator-config.yaml"), @@ -39,6 +41,14 @@ let package = Package( package: "swift-openapi-generator" ) ] - ) + ), + .executableTarget( + name: "LambdaAuthorizer", + dependencies: [ + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), + .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), + ], + path: "Sources/LambdaAuthorizer" + ), ] ) diff --git a/Examples/quoteapi/README.md b/Examples/quoteapi/README.md index b68303e..a03de35 100644 --- a/Examples/quoteapi/README.md +++ b/Examples/quoteapi/README.md @@ -20,6 +20,11 @@ The **sam build** command uses Docker to compile your Swift Lambda function and sam build ``` +On macOS, you might need to run this command if `sam` doesn't see `docker`: +```bash +export DOCKER_HOST=unix://$HOME/.docker/run/docker.sock +``` + ## Deploy the application The **sam deploy** command creates the Lambda function and API Gateway in your AWS account. @@ -28,13 +33,7 @@ The **sam deploy** command creates the Lambda function and API Gateway in your A sam deploy --guided ``` -Accept the default response to every prompt, except the following warning: - -```bash -QuoteService may not have authorization defined, Is this okay? [y/N]: y -``` - -The project creates a publicly accessible API endpoint. This is a warning to inform you the API does not have authorization. If you are interested in adding authorization to the API, please refer to the [SAM Documentation](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-httpapi.html). +The project creates an API endpoint protected by a bearer token authorization. Use token value '123' while testing. Youc an change the token validation logic in the `LambdaAuthorizer` function. To learn more about Lambda authorizer function, refer to [the API Gateway documentation](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html). ## Use the API @@ -54,7 +53,7 @@ Use cURL or a tool such as [Postman](https://www.postman.com/) to interact with **Invoke the API Endpoint** ```bash -curl https://[your-api-endpoint]/stocks/AMZN +curl -H 'Authorization: Bearer 123' https://[your-api-endpoint]/stocks/AMZN ``` ## Test the API Locally @@ -70,6 +69,11 @@ When a Lambda function is invoked, API Gateway sends an event to the function wi sam local invoke QuoteService --event events/GetQuote.json ``` +On macOS, you might need to run this command if `sam` doesn't see `docker`: +```bash +export DOCKER_HOST=unix://$HOME/.docker/run/docker.sock +``` + ## Cleanup When finished with your application, use SAM to delete it from your AWS account. Answer **Yes (y)** to all prompts. This will delete all of the application resources created in your AWS account. diff --git a/Examples/quoteapi/Sources/LambdaAuthorizer/main.swift b/Examples/quoteapi/Sources/LambdaAuthorizer/main.swift new file mode 100644 index 0000000..5658529 --- /dev/null +++ b/Examples/quoteapi/Sources/LambdaAuthorizer/main.swift @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenAPI Lambda open source project +// +// Copyright (c) 2023 Amazon.com, Inc. or its affiliates +// and the Swift OpenAPI Lambda project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift OpenAPI Lambda project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2024 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import AWSLambdaEvents +import AWSLambdaRuntime + +// +// This is an example of a simple authorizer that always authorizes the request. +// A simple authorizer returns a yes/no decision and optional context key-value pairs +// +// Warning: this is an overly simplified authentication strategy, checking +// for the presence of a token. +// +// In your project, here you would likely call out to a library that performs +// a cryptographic validation, or similar. +// +// The code is for illustrative purposes only and should not be used directly. +let simpleAuthorizerHandler: + (APIGatewayLambdaAuthorizerRequest, LambdaContext) async throws -> APIGatewayLambdaAuthorizerSimpleResponse = { + (request: APIGatewayLambdaAuthorizerRequest, context: LambdaContext) in + + context.logger.debug("+++ Simple Authorizer called +++") + + guard let authToken = request.headers["authorization"] + else { + context.logger.warning("Missing Authorization header") + return .init(isAuthorized: false, context: [:]) + } + + // do not take an authorization decision here. + // bring the token to the OpenAPI service and let the developer + // verify authorization there. + + return APIGatewayLambdaAuthorizerSimpleResponse( + // this is the authorization decision: yes or no + isAuthorized: true, + + // this is additional context we want to return to the caller + // these values can be retrieved in requestContext.authorizer of the APIGatewayv2 request + context: ["token": authToken] + ) + } + +// create the runtime and start polling for new events. +// in this demo we use the simple authorizer handler +let runtime = LambdaRuntime(body: simpleAuthorizerHandler) +try await runtime.run() + +// Another, more complex, example +// +// This is an example of a policy authorizer that always authorizes the request. +// The policy authorizer returns an IAM policy document that defines what the Lambda function caller can do and optional context key-value pairs +// +// This code is shown for the example only and is not used in this demo. +// This code doesn't perform any type of token validation. It should be used as a reference only. +// let policyAuthorizerHandler: +// (APIGatewayLambdaAuthorizerRequest, LambdaContext) async throws -> APIGatewayLambdaAuthorizerPolicyResponse = { +// (request: APIGatewayLambdaAuthorizerRequest, context: LambdaContext) in + +// context.logger.debug("+++ Policy Authorizer called +++") + +// // typically, this function will check the validity of the incoming token received in the request + +// // then it creates and returns a response +// return APIGatewayLambdaAuthorizerPolicyResponse( +// principalId: "John Appleseed", + +// // this policy allows the caller to invoke any API Gateway endpoint +// policyDocument: .init(statement: [ +// .init( +// action: "execute-api:Invoke", +// effect: .allow, +// resource: "*" +// ) + +// ]), + +// // this is additional context we want to return to the caller +// context: [ +// "abc1": "xyz1", +// "abc2": "xyz2", +// ] +// ) +// } diff --git a/Examples/quoteapi/Sources/QuoteAPI/AuthenticateUserMiddleware.swift b/Examples/quoteapi/Sources/QuoteAPI/AuthenticateUserMiddleware.swift new file mode 100644 index 0000000..f93ba7c --- /dev/null +++ b/Examples/quoteapi/Sources/QuoteAPI/AuthenticateUserMiddleware.swift @@ -0,0 +1,82 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenAPI Lambda open source project +// +// Copyright (c) 2023 Amazon.com, Inc. or its affiliates +// and the Swift OpenAPI Lambda project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift OpenAPI Lambda project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import OpenAPIRuntime +import HTTPTypes + +/// A server middleware that authenticates the incoming user based on the value of +/// the `Authorization` header field and injects the identifier `User` information +/// into a task local value, allowing the request handler to use it. +package struct AuthenticationServerMiddleware: Sendable { + + /// Information about an authenticated user. + package struct User: Hashable { + + /// The name of the authenticated user. + package var name: String + + /// Creates a new user. + /// - Parameter name: The name of the authenticated user. + package init(name: String) { self.name = name } + + /// The task local value of the currently authenticated user. + @TaskLocal package static var current: User? + } + + /// The closure that authenticates the user based on the value of the `Authorization` + /// header field. + private let authenticate: @Sendable (String) -> User? + + /// Creates a new middleware. + /// - Parameter authenticate: The closure that authenticates the user based on the value + /// of the `Authorization` header field. + package init(authenticate: @Sendable @escaping (String) -> User?) { self.authenticate = authenticate } +} + +extension AuthenticationServerMiddleware: ServerMiddleware { + package func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + metadata: ServerRequestMetadata, + operationID: String, + next: @Sendable (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + // Extracts the `Authorization` value, if present. + // Even if when we use a Lambda authorizer, the original authorization header is forwarded + // If no `Authorization` header field value was provided, no User is injected into + // the task local. + guard let authorizationHeaderFieldValue = request.headerFields[.authorization] else { + return try await next(request, body, metadata) + } + + // Delegate the authentication logic to the closure. + let user = authenticate(authorizationHeaderFieldValue) + // Inject the authenticated user into the task local and call the next middleware. + return try await User.$current.withValue(user) { try await next(request, body, metadata) } + } +} diff --git a/Examples/quoteapi/Sources/QuoteAPI/QuoteService.swift b/Examples/quoteapi/Sources/QuoteAPI/QuoteService.swift new file mode 100644 index 0000000..2070cf5 --- /dev/null +++ b/Examples/quoteapi/Sources/QuoteAPI/QuoteService.swift @@ -0,0 +1,159 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenAPI Lambda open source project +// +// Copyright (c) 2023 Amazon.com, Inc. or its affiliates +// and the Swift OpenAPI Lambda project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift OpenAPI Lambda project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import Logging +import OpenAPIRuntime +import OpenAPILambda +import ServiceLifecycle + +@main +struct QuoteServiceImpl: APIProtocol, OpenAPILambdaHttpApi { + + let logger: Logger + + func register(transport: OpenAPILambdaTransport) throws { + + // OPTIONAL + // you have a chance here to customize the routes, for example + try transport.router.get("/health") { _, _ in + "OK" + } + logger.trace("Available Routes\n\(transport.router)") // print the router tree (for debugging purposes) + + // OPTIONAL + // to log all requests and their responses, add a logging middleware + let loggingMiddleware = LoggingMiddleware(logger: logger) + + // OPTIONAL + // This app includes a sample authorization middleware + // It transforms the bearer token into a username. + // The user name can be access through a TaskLocal variable. + let authenticationMiddleware = self.authenticationMiddleware() + + // MANDATORY (middlewares are optional) + try self.registerHandlers(on: transport, middlewares: [loggingMiddleware, authenticationMiddleware]) + } + + static func main() async throws { + + // when you just need to run the Lambda function, call Self.run() + let openAPIService = QuoteServiceImpl(i: 42) // with dependency injection + try await openAPIService.run() + + // ============================= + + // when you need to have access to the runtime for advanced usage + // (dependency injection, Service LifeCycle, etc) + // + // 1. Create the open API lambda service, + // 2. Pass it to an OpenAPI Lambda Handler + // 3. Create the Lambda Runtime service, passing the handler + // 4. Either start the runtime, or add it to a service lifecycle group. + + // Here is an example where you start your own Lambda runtime + + // let openAPIService = QuoteServiceImpl(i: 42) // 1. + // let lambda = try OpenAPILambdaHandler(withService: openAPIService) // 2. + // let lambdaRuntime = LambdaRuntime(body: lambda.handle) // 3. + // try await lambdaRuntime.run() // 4. + + + // ============================= + + // Here is an example with Service Lifecycle + // Add the following in Package.swift + // .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.8.0"), + // and + // .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), + // Add `import ServiceLifecycle` at the top of this file` + + // let openAPIService = QuoteServiceImpl(i: 42) // 1. + // let lambda = try OpenAPILambdaHandler(withService: openAPIService) // 2. + // let lambdaRuntime = LambdaRuntime(body: lambda.handle) // 3. + // let serviceGroup = ServiceGroup( + // services: [lambdaRuntime], + // gracefulShutdownSignals: [.sigterm], + // cancellationSignals: [.sigint], + // logger: Logger(label: "ServiceGroup") + // ) + // try await serviceGroup.run() // 4. + + } + + // example of dependency injection + let i: Int + init(i: Int) { + self.i = i + var logger = Logger(label: "QuoteService") + logger.logLevel = .trace + self.logger = logger + } + + func getQuote(_ input: Operations.getQuote.Input) async throws -> Operations.getQuote.Output { + + // OPTIONAL + // Check if the Authentication Middleware has been able to authenticate the user + guard let user = AuthenticationServerMiddleware.User.current else { return .unauthorized(.init()) } + + // You can log events to the AWS Lambda logs here + logger.trace("GetQuote for \(user) - Started") + + let symbol = input.path.symbol + + var date: Date = Date() + if let dateString = input.query.date { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyyMMdd" + date = dateFormatter.date(from: dateString) ?? Date() + } + + 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 + ) + + logger.trace("GetQuote - Returning") + + return .ok(.init(body: .json(price))) + } + + func authenticationMiddleware() -> AuthenticationServerMiddleware { + AuthenticationServerMiddleware(authenticate: { stringValue in + // Warning: this is an overly simplified authentication strategy, checking + // for well-known tokens. + // + // In your project, here you would likely call out to a library that performs + // a cryptographic validation, or similar. + // + // The code is for illustrative purposes only and should not be used directly. + switch stringValue { + case "123": + // A known user authenticated. + return .init(name: "Seb") + case "456": + // A known user authenticated. + return .init(name: "Nata") + default: + // Unknown credentials, no authenticated user. + return nil + } + }) + } +} diff --git a/Examples/quoteapi/Sources/openapi-generator-config.yaml b/Examples/quoteapi/Sources/QuoteAPI/openapi-generator-config.yaml similarity index 100% rename from Examples/quoteapi/Sources/openapi-generator-config.yaml rename to Examples/quoteapi/Sources/QuoteAPI/openapi-generator-config.yaml diff --git a/Examples/quoteapi/Sources/openapi.yaml b/Examples/quoteapi/Sources/QuoteAPI/openapi.yaml similarity index 94% rename from Examples/quoteapi/Sources/openapi.yaml rename to Examples/quoteapi/Sources/QuoteAPI/openapi.yaml index 726129f..3151706 100644 --- a/Examples/quoteapi/Sources/openapi.yaml +++ b/Examples/quoteapi/Sources/QuoteAPI/openapi.yaml @@ -50,5 +50,7 @@ paths: $ref: '#/components/schemas/quote' 400: description: Bad Request + 401: + description: Authentication required 404: description: Not Found diff --git a/Examples/quoteapi/Sources/QuoteService.swift b/Examples/quoteapi/Sources/QuoteService.swift deleted file mode 100644 index d233596..0000000 --- a/Examples/quoteapi/Sources/QuoteService.swift +++ /dev/null @@ -1,49 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift OpenAPI Lambda open source project -// -// Copyright (c) 2023 Amazon.com, Inc. or its affiliates -// and the Swift OpenAPI Lambda project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of Swift OpenAPI Lambda project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Foundation -import OpenAPIRuntime -import OpenAPILambda - -@main -struct QuoteServiceImpl: APIProtocol, OpenAPILambdaHttpApi { - - init(transport: OpenAPILambdaTransport) throws { - try self.registerHandlers(on: transport) - } - - func getQuote(_ input: Operations.getQuote.Input) async throws -> Operations.getQuote.Output { - - let symbol = input.path.symbol - - var date: Date = Date() - if let dateString = input.query.date { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyyMMdd" - date = dateFormatter.date(from: dateString) ?? Date() - } - - 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))) - } -} diff --git a/Examples/quoteapi/events/404.json b/Examples/quoteapi/events/404.json new file mode 100644 index 0000000..15a4dd7 --- /dev/null +++ b/Examples/quoteapi/events/404.json @@ -0,0 +1,34 @@ +{ + "rawQueryString": "", + "headers": { + "host": "b2k1t8fon7.execute-api.us-east-1.amazonaws.com", + "x-forwarded-port": "443", + "content-length": "0", + "x-amzn-trace-id": "Root=1-6571d134-63dbe8ee21efa87555d59265", + "x-forwarded-for": "191.95.148.219", + "x-forwarded-proto": "https", + "accept": "*/*", + "user-agent": "curl/8.1.2" + }, + "requestContext": { + "apiId": "b2k1t8fon7", + "http": { + "sourceIp": "191.95.148.219", + "userAgent": "curl/8.1.2", + "method": "GET", + "path": "/undefined", + "protocol": "HTTP/1.1" + }, + "timeEpoch": 1701957940365, + "domainPrefix": "b2k1t8fon7", + "accountId": "486652066693", + "time": "07/Dec/2023:14:05:40 +0000", + "stage": "$default", + "domainName": "b2k1t8fon7.execute-api.us-east-1.amazonaws.com", + "requestId": "Pk2gOia2IAMEPOw=" + }, + "isBase64Encoded": false, + "version": "2.0", + "routeKey": "$default", + "rawPath": "/undefined" +} \ No newline at end of file diff --git a/Examples/quoteapi/events/GetQuote.json b/Examples/quoteapi/events/GetQuote.json index 40e0e20..c8ace6e 100644 --- a/Examples/quoteapi/events/GetQuote.json +++ b/Examples/quoteapi/events/GetQuote.json @@ -8,7 +8,8 @@ "x-forwarded-for": "191.95.148.219", "x-forwarded-proto": "https", "accept": "*/*", - "user-agent": "curl/8.1.2" + "user-agent": "curl/8.1.2", + "authorization": "Bearer 123" }, "requestContext": { "apiId": "b2k1t8fon7", @@ -19,6 +20,11 @@ "path": "/stocks/AAPL", "protocol": "HTTP/1.1" }, + "authorizer": { + "lambda": { + "abc1": "xyz1" + } + }, "timeEpoch": 1701957940365, "domainPrefix": "b2k1t8fon7", "accountId": "486652066693", diff --git a/Examples/quoteapi/events/HealthCheck.json b/Examples/quoteapi/events/HealthCheck.json new file mode 100644 index 0000000..3c879fd --- /dev/null +++ b/Examples/quoteapi/events/HealthCheck.json @@ -0,0 +1,34 @@ +{ + "rawQueryString": "", + "headers": { + "host": "b2k1t8fon7.execute-api.us-east-1.amazonaws.com", + "x-forwarded-port": "443", + "content-length": "0", + "x-amzn-trace-id": "Root=1-6571d134-63dbe8ee21efa87555d59265", + "x-forwarded-for": "191.95.148.219", + "x-forwarded-proto": "https", + "accept": "*/*", + "user-agent": "curl/8.1.2" + }, + "requestContext": { + "apiId": "b2k1t8fon7", + "http": { + "sourceIp": "191.95.148.219", + "userAgent": "curl/8.1.2", + "method": "GET", + "path": "/health", + "protocol": "HTTP/1.1" + }, + "timeEpoch": 1701957940365, + "domainPrefix": "b2k1t8fon7", + "accountId": "486652066693", + "time": "07/Dec/2023:14:05:40 +0000", + "stage": "$default", + "domainName": "b2k1t8fon7.execute-api.us-east-1.amazonaws.com", + "requestId": "Pk2gOia2IAMEPOw=" + }, + "isBase64Encoded": false, + "version": "2.0", + "routeKey": "$default", + "rawPath": "/health" +} \ No newline at end of file diff --git a/Examples/quoteapi/template.yml b/Examples/quoteapi/template.yml index 3e487da..14a3507 100644 --- a/Examples/quoteapi/template.yml +++ b/Examples/quoteapi/template.yml @@ -8,12 +8,12 @@ Globals: CodeUri: . Handler: swift.bootstrap Runtime: provided.al2 - MemorySize: 512 + MemorySize: 128 Architectures: - arm64 Resources: - # Lambda function + # QuoteService Lambda function QuoteService: Type: AWS::Serverless::Function Properties: @@ -32,12 +32,25 @@ Resources: ApiId: !Ref MyProtectedApi Path: /{proxy+} Method: ANY - Auth: - Authorizer: MyLambdaAuthorizer Metadata: BuildMethod: makefile + # Lambda authorizer function + LambdaAuthorizer: + Type: AWS::Serverless::Function + Properties: + Timeout: 29 # max 29 seconds for Lambda authorizers + Environment: + Variables: + # by default, AWS Lambda runtime produces no log + # use `LOG_LEVEL: debug` for for lifecycle and event handling information + # use `LOG_LEVEL: trace` for detailed input event information + LOG_LEVEL: trace + Metadata: + BuildMethod: makefile + + # The API Gateway MyProtectedApi: Type: AWS::Serverless::HttpApi Properties: @@ -45,17 +58,24 @@ Resources: DefaultAuthorizer: MyLambdaAuthorizer Authorizers: MyLambdaAuthorizer: - AuthorizerPayloadFormatVersion: 2.0 - EnableFunctionDefaultPermissions: true - EnableSimpleResponses: true - FunctionArn: arn:aws:lambda:us-east-1:486652066693:function:LambdaAuthorizer-LambdaAuthorizer-TSH4AsHiqICi + FunctionArn: !GetAtt LambdaAuthorizer.Arn Identity: Headers: - - Authorization + - Authorization + AuthorizerPayloadFormatVersion: "2.0" + EnableSimpleResponses: true + + # Give the API Gateway permissions to invoke the Lambda authorizer + AuthorizerPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref LambdaAuthorizer + Principal: apigateway.amazonaws.com + SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${MyProtectedApi}/* # print API endpoint Outputs: SwiftAPIEndpoint: Description: "API Gateway endpoint URL for your application" Value: !Sub "https://${MyProtectedApi}.execute-api.${AWS::Region}.amazonaws.com" - # Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com" diff --git a/Package.swift b/Package.swift index eaccbfb..e77fe98 100644 --- a/Package.swift +++ b/Package.swift @@ -5,13 +5,12 @@ import PackageDescription let package = Package( name: "swift-openapi-lambda", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v15)], products: [.library(name: "OpenAPILambda", targets: ["OpenAPILambda"])], dependencies: [ - .package(url: "https://github.com/apple/swift-openapi-runtime.git", from: "1.0.0"), - .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "1.0.0-alpha.3"), - .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-openapi-runtime.git", from: "1.8.2"), + .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "2.0.0-beta.3"), + .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "1.2.0"), ], targets: [ .target( @@ -21,8 +20,7 @@ let package = Package( .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), ], - path: "Sources", - swiftSettings: [.enableExperimentalFeature("StrictConcurrency=complete")] + path: "Sources" ), // test targets .testTarget( diff --git a/Package@swift-5.10.swift b/Package@swift-5.10.swift deleted file mode 100644 index b155807..0000000 --- a/Package@swift-5.10.swift +++ /dev/null @@ -1,35 +0,0 @@ -// swift-tools-version: 5.10 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "swift-openapi-lambda", - platforms: [.macOS(.v12)], - products: [.library(name: "OpenAPILambda", targets: ["OpenAPILambda"])], - dependencies: [ - .package(url: "https://github.com/apple/swift-openapi-runtime.git", from: "1.0.0"), - .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "1.0.0-alpha.3"), - .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0"), - ], - targets: [ - .target( - name: "OpenAPILambda", - dependencies: [ - .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), - .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), - .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), - ], - path: "Sources", - swiftSettings: [.enableExperimentalFeature("StrictConcurrency=complete")] - ), - // test targets - .testTarget( - name: "OpenAPILambdaTests", - dependencies: [ - .byName(name: "OpenAPILambda") - ] - ), - ] -) diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift deleted file mode 100644 index 5d1d0b4..0000000 --- a/Package@swift-5.9.swift +++ /dev/null @@ -1,35 +0,0 @@ -// swift-tools-version: 5.9 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "swift-openapi-lambda", - platforms: [.macOS(.v12)], - products: [.library(name: "OpenAPILambda", targets: ["OpenAPILambda"])], - dependencies: [ - .package(url: "https://github.com/apple/swift-openapi-runtime.git", from: "1.0.0"), - .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "1.0.0-alpha.3"), - .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0"), - ], - targets: [ - .target( - name: "OpenAPILambda", - dependencies: [ - .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), - .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), - .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), - ], - path: "Sources", - swiftSettings: [.enableExperimentalFeature("StrictConcurrency=complete")] - ), - // test targets - .testTarget( - name: "OpenAPILambdaTests", - dependencies: [ - .byName(name: "OpenAPILambda") - ] - ), - ] -) diff --git a/README.md b/README.md index 19be796..447e6c8 100644 --- a/README.md +++ b/README.md @@ -1,603 +1,242 @@ [![Build & Test on GitHub](https://github.com/swift-server/swift-openapi-lambda/actions/workflows/pull_request.yml/badge.svg)](https://github.com/swift-server/swift-openapi-lambda/actions/workflows//pull_request.yml) - -![language](https://img.shields.io/badge/swift-5.9-blue) -![language](https://img.shields.io/badge/swift-5.10-blue) ![language](https://img.shields.io/badge/swift-6.0-blue) +![language](https://img.shields.io/badge/swift-6.1-blue) ![platform](https://img.shields.io/badge/platform-macOS-green) ![platform](https://img.shields.io/badge/platform-Linux-orange) [![license](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) -# AWS Lambda transport for Swift OpenAPI +# AWS Lambda transport for Swift OpenAPI 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. - -The library provides two capabilities: +This library allows you to expose server-side Swift OpenAPI implementations as AWS Lambda functions with minimal code changes. -- a default implementation of an AWS Lambda function 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. +The library provides: -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) +- A default AWS Lambda Swift function that consumes your OpenAPI service implementation +- Built-in support for [Amazon API Gateway HTTP API](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html) events +- Re-exported dependencies to minimize `Package.swift` complexity +We strongly recommend to never deploy openly available API. The QuoteAPI example project shows you how to add a Lambda Authorizer function to the API Gateway. ## Prerequisites -To write and deploy AWS Lambda functions based on an OpenAPI API definition, you need the following: - -- an [AWS Account](https://console.aws.amazon.com/) -- the [AWS Command Line Interface (AWS CLI)](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html) - install the CLI and [configure](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html) it with credentials to your AWS account -- the [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) - a command-line tool used to create serverless workloads on AWS -- the [Docker Desktop](https://www.docker.com/products/docker-desktop/) - to compile your Swift code for Linux deployment to AWS Lambda - -## TL;DR +- [AWS Account](https://console.aws.amazon.com/) +- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html) - configured with your AWS credentials +- [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) - for serverless deployment +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) - for cross compilation to Linux, when using macOS or Windows. +## Quick Start 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 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: +### 1. Add dependencies to Package.swift ```swift - dependencies: [ - .package(url: "https://github.com/apple/swift-openapi-generator.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-openapi-runtime.git", from: "1.0.0"), +dependencies: [ + .package(url: "https://github.com/apple/swift-openapi-generator.git", from: "1.4.0"), + .package(url: "https://github.com/apple/swift-openapi-runtime.git", from: "1.8.2"), // add these three dependencies - .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "1.0.0-alpha.1"), - .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "0.1.0"), - .package(url: "https://github.com/sebsto/swift-openapi-lambda", from: "0.1.1") - ], -``` + .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "2.0.0-beta.3"), + .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "1.2.0"), + .package(url: "https://github.com/swift-server/swift-openapi-lambda.git", from: "2.0.0"), +], +targets: [ + .executableTarget( + name: "YourOpenAPIService", + dependencies: [ + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), - The target dependencies: - -```swift - .executableTarget( - name: "YourOpenAPIService", - dependencies: [ - .product(name: "OpenAPIRuntime",package: "swift-openapi-runtime"), - - // add these three dependencies - .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), - .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), - .product(name: "OpenAPILambda",package: "swift-openapi-lambda"), - ], + // add these three products + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), + .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), + .product(name: "OpenAPILambda", package: "swift-openapi-lambda"), + ] + ) +] ``` -2. Add a protocol and a constructor to your existing OpenAPI service implementation +### 2. Update your service implementation -There are only four changes to make to your existing implementation: +Add a protocol and a constructor to your existing OpenAPI service implementation + +There are only five changes to make to your existing implementation: ![Animated GIF to show the changes](assets/swift-openapi-lambda.gif) ```swift -import Foundation import OpenAPIRuntime import OpenAPILambda // <-- 1. import this library @main // <-- 2. flag this struct as the executable target entrypoint struct QuoteServiceImpl: APIProtocol, OpenAPILambdaHttpApi { // <-- 3. add the OpenAPILambdaHttpApi protocol - init(transport: OpenAPILambdaTransport) throws { // <-- 4. add this constructor (don't remove the call to `registerHandlers(on:)`) + // The registration of your OpenAPI handlers + func register(transport: OpenAPILambdaTransport) throws { // <-- 4. add this method (calls registerHandlers) 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`) - -🎉 Enjoy! - -## Tutorial (a Quick Start with a Stock Quote API service example) - -### Part 1 - the code - -1. Create a Swift executable project - -```sh -mkdir quoteapi && cd quoteapi -swift package init --name quoteapi --type executable -``` - -2. Write or import an OpenAI API definition in YAML or JSON - -```sh -# -# the $ signs are escaped (\$) to work with the cat << EOF command -# if you choose to copy the content directly to a text editor, -# be sure to remove the \ (that means \$ becomes $) -# -``` - -```yaml -cat << EOF > Sources/openapi.yaml -openapi: 3.1.0 -info: - title: StockQuoteService - version: 1.0.0 - -components: - schemas: - quote: - type: object - properties: - symbol: - type: string - price: - type: number - change: - type: number - changePercent: - type: number - volume: - type: number - timestamp: - type: string - format: date-time - -paths: - /stocks/{symbol}: - get: - summary: Get the latest quote for a stock - operationId: getQuote - parameters: - - name: symbol - in: path - required: true - schema: - type: string - tags: - - stocks - responses: - 200: - description: OK - content: - application/json: - schema: - \$ref: '#/components/schemas/quote' - 400: - description: Bad Request - 404: - description: Not Found -EOF -``` - -3. Add a Swift OpenAPI generator configuration file to generate only the server side - -```sh -cat << EOF > Sources/openapi-generator-config.yaml -generate: - - types - - server -EOF -``` - -4. Use this `Package.swift` file to define targets and their dependencies - -```sh -cat << EOF > Package.swift -// swift-tools-version: 5.9 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "QuoteService", - platforms: [ - .macOS(.v13), .iOS(.v15), .tvOS(.v15), .watchOS(.v6), - ], - products: [ - .executable(name: "QuoteService", targets: ["QuoteService"]), - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-openapi-generator.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-openapi-runtime.git", from: "1.0.0"), - .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "1.0.0-alpha.1"), - .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "0.1.0"), - .package(url: "https://github.com/sebsto/swift-openapi-lambda", from: "0.1.1") - ], - targets: [ - .executableTarget( - name: "QuoteService", - dependencies: [ - .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), - .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), - .product(name: "OpenAPIRuntime",package: "swift-openapi-runtime"), - .product(name: "OpenAPILambda",package: "swift-openapi-lambda"), - ], - path: "Sources", - resources: [ - .copy("openapi.yaml"), - .copy("openapi-generator-config.yaml") - ], - plugins: [ - .plugin( - name: "OpenAPIGenerator", - package: "swift-openapi-generator" - ) - ] - ), - ] -) -EOF -``` - -5. Generate server side Swift stub of the OpenAPI API definition - -```sh -swift build -``` - -6. Replace `main.swift` with your own implementation - -```sh -rm Sources/main.swift -cat << EOF > Sources/QuoteService.swift -import Foundation -import OpenAPIRuntime -import OpenAPILambda - -@main -struct QuoteServiceImpl: APIProtocol, OpenAPILambdaHttpApi { - - init(transport: OpenAPILambdaTransport) throws { - try self.registerHandlers(on: transport) + // the entry point for your Lambda function + static func main() async throws { // <-- 5. add this entry point to start the lambdaRuntime + let openAPIService = QuoteServiceImpl() + try await openAPIService.run() } + // Your existing OpenAPI implementation 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()) - + timestamp: Date() + ) return .ok(.init(body: .json(price))) } } -EOF ``` -7. Build the project to ensure everything works +### 3. Deploy with SAM -```sh -swift build +```bash +sam build && sam deploy --guided ``` -### Part 2 - the deployment +## Complete Example -1. Add the Lambda build instructions as a Docker file and a Makefile. We build for Swift 5.9 on Amazon Linux 2 +See the [Examples/quoteapi](Examples/quoteapi) directory for a complete working example that includes: -```sh -# -# the $ signs are escaped (\$) to work with the cat << EOF command -# if you choose to copy the content directly to a text editor, -# be sure to remove the \ (that means \$ becomes $) -# -``` +- Stock quote API with OpenAPI 3.1 specification +- Lambda authorizer for protected endpoints +- Use `make` for common commands +- SAM deployment configuration +- Local testing setup -```sh -cat << EOF > Dockerfile -# image used to compile your Swift code -FROM public.ecr.aws/docker/library/swift:5.9.1-amazonlinux2 -RUN yum -y install git jq tar zip openssl-devel -EOF +## Testing -cat << EOF > Makefile -### Add functions here and link them to builder-bot format MUST BE "build-FunctionResourceName in template.yaml" +### Local Development -build-QuoteService: builder-bot +```bash +# Run locally with built-in development server +swift run QuoteService -# Helper commands -deploy: - sam deploy - -logs: - sam logs --stack-name QuoteService --name QuoteService - -tail: - sam logs --stack-name QuoteService --name QuoteService --tail - -###################### No Change required below this line ########################## - -builder-bot: - \$(eval \$@PRODUCT = \$(subst build-,,\$(MAKECMDGOALS))) - \$(eval \$@BUILD_DIR = \$(PWD)/.aws-sam/build-swift) - \$(eval \$@STAGE = \$(\$@BUILD_DIR)/lambda) - \$(eval \$@ARTIFACTS_DIR = \$(PWD)/.aws-sam/build/\$(\$@PRODUCT)) - - # build docker image to compile Swift for Linux - docker build -f Dockerfile . -t swift-builder - - # prep directories - mkdir -p \$(\$@BUILD_DIR)/lambda \$(\$@ARTIFACTS_DIR) - - # compile application inside Docker image using source code from local project folder - docker run --rm -v \$(\$@BUILD_DIR):/build-target -v \`pwd\`:/build-src -w /build-src swift-builder bash -cl "swift build --static-swift-stdlib --product \$(\$@PRODUCT) -c release --build-path /build-target" - - # create lambda bootstrap file - docker run --rm -v \$(\$@BUILD_DIR):/build-target -v \`pwd\`:/build-src -w /build-src swift-builder bash -cl "cd /build-target/lambda && ln -s \$(\$@PRODUCT) /bootstrap" - - # copy binary to stage - cp \$(\$@BUILD_DIR)/release/\$(\$@PRODUCT) \$(\$@STAGE)/bootstrap - - # copy app from stage to artifacts dir - cp \$(\$@STAGE)/* \$(\$@ARTIFACTS_DIR) - -EOF +# Test from another terminal +curl -H 'Authorization: Bearer 123' -X POST \ + --data @events/GetQuote.json \ + http://127.0.0.1:7000/invoke ``` -2. Add a SAM template to deploy the Lambda function and the API Gateway +### Production Testing -```sh -# -# the $ signs are escaped (\$) to work with the cat << EOF command -# if you choose to copy the content directly to a text editor, -# be sure to remove the \ (that means \$ becomes $) -# +```bash +# Test deployed API (replace with your endpoint) +curl -H 'Authorization: Bearer 123' \ + https://your-api-id.execute-api.region.amazonaws.com/stocks/AAPL ``` -```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: - # pass through all HTTP verbs and paths - Api: - Type: HttpApi - Properties: - Path: /{proxy+} - Method: ANY - - 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 create an API Gateway in front of it - -```sh -# use --guided for the first deployment only. -# SAM cli collects a few parameters and store them in `samconfig.toml` - -sam deploy --guided --stack-name QuoteService -``` - -Accept all the default values, except: - -```sh -QuoteService has no authentication. Is this okay? [y/N]: <-- answer Y here -``` - -This command outputs the URL of the API Gateway, for example: - -```sh -Outputs ------------------------------------------------------------------------------------------------------------------------------ -Key SwiftAPIEndpoint -Description API Gateway endpoint URL for your application -Value https://747ukfmah7.execute-api.us-east-1.amazonaws.com ------------------------------------------------------------------------------------------------------------------------------ -``` - -5. Test your setup - -```sh -curl [[ Replace with SWIFTAPIEndpoint value ]]/stocks/AAPL -{ - "change" : -4, - "changePercent" : -0.030052760210257923, - "price" : 111, - "symbol" : "AAPL", - "timestamp" : "2023-12-13T03:12:35Z", - "volume" : 63812 -} -``` +## Deployment Costs -## Deployment costs +New AWS accounts get 1 million Lambda invocations and 1 million API Gateway requests free per month. After the free tier, costs are approximately $1.00 per million API calls. -On new AWS accounts, there is no costs associated with the deployment and test of this sample code, up to 1 million invocation per month. It falls under the permananet AWS free Tier for [AWS Lambda](https://aws.amazon.com/lambda/pricing/) and [Amazon API Gateway](https://aws.amazon.com/api-gateway/pricing/) +## Cleanup -When you're account is older than a year, you are charged $1.0 per million calls to the API Gateway. AWS Lambda function invocation stays free up to 4 million invocations and 400.000 GB-second compute time per month. - -## Cleanup - -To delete the AWS Lambda function, the API Gateway, and the roles and permissions created with `sam`, just type: - -```sh +```bash sam delete ``` -## Local Testing - -``` -git clone https://github.com/sebsto/swift-openapi-lambda.git && cd swift-openapi-lambda -# In the directory of the Swift OpenAPI Lambda transport project -LOCAL_LAMBDA_SERVER_ENABLED=true swift run - -# from another terminal, in the directory of the QuoteAPI sample project -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 AWS Lambda event types - -When you expose your AWS Lambda function to other event types, you have to specialize the `OpenAPILambda` protocol and implement the two methods that transform your Lambda function input data to an `OpenAPIRequest` and the other way around, transform an `OpenAPIResponse` to your Lambda function output type. +## Lambda Authorizers -Here is an example with the [Amazon API Gateway (Rest Api)](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.htm), (aka the original API Gateway). - -I start with an OpenAPI stub implementation - unmodified. +The library supports Lambda authorizers for API protection. See [Examples/quoteapi/Sources/LambdaAuthorizer](Examples/quoteapi/Sources/LambdaAuthorizer) for a complete implementation that validates a Bearer token. ```swift -import Foundation -import OpenAPIRuntime - -struct QuoteServiceImpl: APIProtocol { - 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))) - } +let simpleAuthorizerHandler: (APIGatewayLambdaAuthorizerRequest, LambdaContext) async throws -> APIGatewayLambdaAuthorizerSimpleResponse = { + guard let authToken = $0.headers["authorization"], + authToken == "Bearer 123" else { + return .init(isAuthorized: false, context: [:]) + } + return .init(isAuthorized: true, context: ["user": "authenticated"]) } ``` -Next, I implement a `struct` that conforms to `OpenAPILambda`. This `struct` defines: +## Advanced Usage -- the event input and output the Lambda function will work on (from [AWS Lambda Event Types](https://github.com/swift-server/swift-aws-lambda-events) library). -- the mandatory constructor `init(transport:)` -- the executable target entrypoint (`@main`) +### Custom Event Types -Here is an example using `APIGatewayRequest` and `APIGatewayResponse`: +To support other Lambda event types beyond API Gateway, implement the `OpenAPILambda` protocol: ```swift @main -struct QuoteServiceLambda: OpenAPILambda { - typealias Event = APIGatewayRequest - typealias Output = APIGatewayResponse - public init(transport: OpenAPILambdaTransport) throws { - let openAPIHandler = QuoteServiceImpl() - try openAPIHandler.registerHandlers(on: transport) +struct CustomServiceLambda: OpenAPILambda { + typealias Event = YourCustomEvent + typealias Output = YourCustomResponse + + func register(transport: OpenAPILambdaTransport) throws { + let handler = YourServiceImpl() + try handler.registerHandlers(on: transport) + } + + func request(context: LambdaContext, from event: Event) throws -> OpenAPILambdaRequest { + // Transform your event to HTTPRequest + } + + func output(from response: OpenAPILambdaResponse) -> Output { + // Transform HTTPResponse to your output type } } ``` -Next step is to implement two methods from `OpenAPILambda` protocol to convert your Lambda function input data (`APIGatewayRequest`) to an `OpenAPIRequest` and the other way around, transform an `OpenAPIResponse` to your Lambda function output type (`APIGatewayResponses`). +### Service Lifecycle Integration ```swift -extension OpenAPILambda where Event == APIGatewayRequest { - /// Transform a Lambda input (`APIGatewayRequest` 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 == APIGatewayResponse { - /// Transform an OpenAPI response (`HTTPResponse`, `String?`) to a Lambda Output (`APIGatewayResponse`) - public func output(from response: OpenAPILambdaResponse) -> Output { - var apiResponse = APIGatewayResponse(from: response.0) - apiResponse.body = response.1 - return apiResponse - } +import ServiceLifecycle + +// In your OpenAPI service, explicitly create and manage the LambdaRuntime +static func main() async throws { + let lambdaRuntime = try LambdaRuntime(body: Self.handler()) + let serviceGroup = ServiceGroup( + services: [lambdaRuntime], + gracefulShutdownSignals: [.sigterm], + cancellationSignals: [.sigint], + logger: Logger(label: "ServiceGroup") + ) + try await serviceGroup.run() } ``` -To keep the above code short, simple, and readable, we suggest to implement whatever extension on the Lambda source Event type. Here are the extensions required to support the above code. These are simple data transformation methods from one type to the other. +### Dependency Injection + +For advanced use cases requiring dependency injection: ```swift -extension APIGatewayRequest { +@main +struct QuoteServiceImpl: APIProtocol, OpenAPILambdaHttpApi { + let customDependency: Int - /// Return an `HTTPRequest.Method` for this `APIGatewayRequest` - public func httpRequestMethod() throws -> HTTPRequest.Method { - guard let method = HTTPRequest.Method(rawValue: self.httpMethod.rawValue) else { - throw OpenAPILambdaHttpError.invalidMethod(self.httpMethod.rawValue) - } - return method + init(customDependency: Int = 0) { + self.customDependency = customDependency } - - /// Return an `HTTPRequest` for this `APIGatewayV2Request` - public func httpRequest() throws -> HTTPRequest { - try HTTPRequest( - method: self.httpRequestMethod(), - scheme: "https", - authority: "", - path: self.path, - headerFields: self.headers.httpFields() - ) + + // the entry point can be in another file / struct as well. + static func main() async throws { + let service = QuoteServiceImpl(customDependency: 42) + let lambda = try OpenAPILambdaHandler(service: service) + let lambdaRuntime = LambdaRuntime(body: lambda.handler) + try await lambdaRuntime.run() } -} - -extension APIGatewayResponse { - /// Create a `APIGatewayV2Response` from an `HTTPResponse` - public init(from response: HTTPResponse) { - self = APIGatewayResponse( - statusCode: .init(code: UInt(response.status.code)), - headers: .init(from: response.headerFields), - isBase64Encoded: false - ) + func register(transport: OpenAPILambdaTransport) throws { + try self.registerHandlers(on: transport) } } ``` -You can apply the same design to support other AWS Lambda event types. However, keep in mind that the `OpenAPILAmbda` implementation is heavily biased towards receiving, routing, and responding to HTTP requests. - -## References - -### Swift OpenAPI generator - -To get started with the Swift OpenAPI generator, check out the full [documentation](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation), which contains [a step-by-step tutorial](https://swiftpackageindex.com/apple/swift-openapi-generator/0.3.5/tutorials/swift-openapi-generator). - - -### Swift on AWS Lambda - -The Swift Runtime for AWS Lambda allows you to write AWS Lambda functions in the Swift programming language. - -To get started, check out [this step-by-step tutorial](https://swiftpackageindex.com/swift-server/swift-aws-lambda-runtime/main/tutorials/table-of-content) and [the documentation](https://swiftpackageindex.com/swift-server/swift-aws-lambda-runtime). - -### Serverless Application Model (SAM) +## References -Read "[What is SAM](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html)" to understand and get started with SAM. +- [Swift OpenAPI Generator](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation) - Complete documentation and tutorials +- [Swift AWS Lambda Runtime](https://swiftpackageindex.com/swift-server/swift-aws-lambda-runtime) - Swift runtime for AWS Lambda +- [AWS SAM](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html) - Serverless Application Model documentation +- [API Gateway Lambda Authorizers](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html) - Lambda authorization documentation \ No newline at end of file diff --git a/Sources/Exports.swift b/Sources/Exports.swift new file mode 100644 index 0000000..ceb8f66 --- /dev/null +++ b/Sources/Exports.swift @@ -0,0 +1,18 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenAPI Lambda open source project +// +// Copyright (c) 2025 Amazon.com, Inc. or its affiliates +// and the Swift OpenAPI Lambda project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift OpenAPI Lambda project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// re-export AWSLambdaRuntime and AWSLambdaEvents so that users don't have to. +@_exported import AWSLambdaRuntime +@_exported import AWSLambdaEvents diff --git a/Sources/HttpApi/OpenAPILambdaHttpApi.swift b/Sources/HttpApi/OpenAPILambdaHttpApi.swift index 6220d90..226fda3 100644 --- a/Sources/HttpApi/OpenAPILambdaHttpApi.swift +++ b/Sources/HttpApi/OpenAPILambdaHttpApi.swift @@ -24,7 +24,7 @@ public enum OpenAPILambdaHttpError: Error { } /// An specialization of the `OpenAPILambda` protocol that works with Amazon API Gateway HTTP Mode, aka API Gateway v2 -public protocol OpenAPILambdaHttpApi: OpenAPILambda +public protocol OpenAPILambdaHttpApi: OpenAPILambdaService where Event == APIGatewayV2Request, Output == APIGatewayV2Response diff --git a/Sources/Middleware/LoggingMiddleware.swift b/Sources/Middleware/LoggingMiddleware.swift new file mode 100644 index 0000000..39aa81a --- /dev/null +++ b/Sources/Middleware/LoggingMiddleware.swift @@ -0,0 +1,75 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenAPI Lambda open source project +// +// Copyright (c) 2023 Amazon.com, Inc. or its affiliates +// and the Swift OpenAPI Lambda project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift OpenAPI Lambda project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes +import Logging +import OpenAPIRuntime + +/// A middleware that logs request and response metadata. +/// Only active at .trace level +public struct LoggingMiddleware: ServerMiddleware { + + private let logger: Logger + + /// A middleware that logs request and response metadata. + /// Only active at .trace level + /// + /// - Parameters: + /// - logger: The logger instance to use for logging request/response data + /// + /// - Note: This middleware only logs at .trace level and will not generate output at other log levels + /// + public init(logger: Logger) { + self.logger = logger + } + + /// Intercepts HTTP requests and logs request/response metadata at trace level. + /// + /// - Parameters: + /// - request: The incoming HTTP request + /// - body: The request body, if any + /// - metadata: Metadata associated with the request + /// - operationID: The OpenAPI operation ID for this request + /// - next: The next middleware/handler in the chain + /// + /// - Returns: A tuple containing the HTTP response and optional response body + /// + /// - Throws: Rethrows any errors from downstream handlers + /// + /// This method logs: + /// - The request method and path on entry + /// - The response status code on successful completion + /// - Error descriptions if an error occurs + public func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + metadata: ServerRequestMetadata, + operationID: String, + next: (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + + logger.trace(">>>: \(request.method.rawValue) \(request.path ?? "")") + do { + let (response, responseBody) = try await next(request, body, metadata) + logger.trace("<<<: \(response.status.code)") + return (response, responseBody) + } + catch { + logger.trace("!!!: \(error.localizedDescription)") + throw error + } + } + +} diff --git a/Sources/OpenAPILambdaHandler.swift b/Sources/OpenAPILambdaHandler.swift index 58d312b..b325a42 100644 --- a/Sources/OpenAPILambdaHandler.swift +++ b/Sources/OpenAPILambdaHandler.swift @@ -18,23 +18,28 @@ import OpenAPIRuntime import HTTPTypes /// Specialization of LambdaHandler which runs an OpenAPILambda -public struct OpenAPILambdaHandler: LambdaHandler { +public struct OpenAPILambdaHandler: LambdaHandler, Sendable { + + private let transport: OpenAPILambdaTransport + private let openAPIService: OALS /// the input type for this Lambda handler (received from the `OpenAPILambda`) - public typealias Event = L.Event + public typealias Event = OALS.Event /// the output type for this Lambda handler (received from the `OpenAPILambda`) - public typealias Output = L.Output + public typealias Output = OALS.Output /// Initialize `OpenAPILambdaHandler`. /// - /// Create application, set it up and create `OpenAPILambda` from application and create responder + /// This initializer decouples the OpenAPILambdaService creation from the registration of the transport + /// this allows users to control the lifecycle of their service and to inject dependencies. + /// /// - Parameters - /// - context: Lambda initialization context - public init(context: LambdaInitializationContext) throws { - self.router = TrieRouter() - self.transport = OpenAPILambdaTransport(router: self.router) - self.lambda = try .init(transport: self.transport) + /// - withService: The OpenAPI Lambda service to bind to this Lambda handler function + public init(withService openAPILambdaService: OALS) throws { + self.openAPIService = openAPILambdaService + self.transport = OpenAPILambdaTransport(router: TrieRouter()) + try self.openAPIService.register(transport: self.transport) } /// The Lambda handling method. @@ -45,17 +50,17 @@ public struct OpenAPILambdaHandler: LambdaHandler { /// - context: Runtime ``LambdaContext``. /// /// - Returns: A Lambda result ot type `Output`. - public func handle(_ request: Event, context: LambdaContext) async throws -> Output { + public func handle(_ event: OALS.Event, context: AWSLambdaRuntime.LambdaContext) async throws -> OALS.Output { - // by default return HTTP 500 + // by default returns HTTP 500 var lambdaResponse: OpenAPILambdaResponse = (HTTPResponse(status: .internalServerError), "unknown error") do { // convert Lambda event source to OpenAPILambdaRequest - let request = try lambda.request(context: context, from: request) + let request = try openAPIService.request(context: context, from: event) // route the request to find the handlers and extract the paramaters - let (handler, parameters) = try await router.route(method: request.0.method, path: request.0.path!) + let (handler, parameters) = try self.transport.router.route(method: request.0.method, path: request.0.path!) // call the request handler (and extract the HTTPRequest and HTTPBody) let httpRequest = request.0 @@ -83,10 +88,12 @@ public struct OpenAPILambdaHandler: LambdaHandler { lambdaResponse = (HTTPResponse(status: .notFound), "There is no route registered for the method \(method)") } - catch OpenAPILambdaRouterError.noRouteForPath(let path) { + catch OpenAPILambdaRouterError.noRouteForPath(let method, let path) { // There is no hadler registered for this path. This is a programming error. - lambdaResponse = (HTTPResponse(status: .notFound), "There is no route registered for the path \(path)") + lambdaResponse = ( + HTTPResponse(status: .notFound), "There is no route registered for the path \(method) \(path)" + ) } catch OpenAPILambdaHttpError.invalidMethod(let method) { @@ -99,12 +106,12 @@ public struct OpenAPILambdaHandler: LambdaHandler { ) } + catch { + // some other error happened + lambdaResponse = (HTTPResponse(status: .internalServerError), "Unknown error: \(String(reflecting: error))") + } // transform the OpenAPILambdaResponse to the Lambda Output - return lambda.output(from: lambdaResponse) + return openAPIService.output(from: lambdaResponse) } - - let router: OpenAPILambdaRouter - let transport: OpenAPILambdaTransport - let lambda: L } diff --git a/Sources/OpenAPILambda.swift b/Sources/OpenAPILambdaService.swift similarity index 56% rename from Sources/OpenAPILambda.swift rename to Sources/OpenAPILambdaService.swift index 9ae193e..3b6b044 100644 --- a/Sources/OpenAPILambda.swift +++ b/Sources/OpenAPILambdaService.swift @@ -14,19 +14,20 @@ //===----------------------------------------------------------------------===// import Foundation import AWSLambdaRuntime +import Logging import OpenAPIRuntime import HTTPTypes /// A Lambda function implemented with a OpenAPI server (implementing `APIProtocol` from Swift OpenAPIRuntime) -public protocol OpenAPILambda { +public protocol OpenAPILambdaService: Sendable { - associatedtype Event: Decodable - associatedtype Output: Encodable + associatedtype Event: Decodable, Sendable + associatedtype Output: Encodable, Sendable - /// Initialize application. + /// Injects the transport. /// - /// This is where you create your OpenAPI service implementation and register the transport - init(transport: OpenAPILambdaTransport) throws + /// This is where your OpenAPILambdaService implementation must register the transport + func register(transport: OpenAPILambdaTransport) throws /// Convert from `Event` type to `OpenAPILambdaRequest` /// - Parameters: @@ -39,11 +40,17 @@ public protocol OpenAPILambda { func output(from: OpenAPILambdaResponse) -> Output } -extension OpenAPILambda { - /// Initializes and runs the Lambda function. +extension OpenAPILambdaService { + + /// Start the Lambda Runtime with the Lambda handler function + /// for this OpenAPI Lambda service implementation with a custom logger, /// - /// If you precede your ``EventLoopLambdaHandler`` conformer's declaration with the - /// [@main](https://docs.swift.org/swift-book/ReferenceManual/Attributes.html#ID626) - /// attribute, the system calls the conformer's `main()` method to launch the lambda function. - public static func main() throws { OpenAPILambdaHandler.main() } + /// - Parameter logger: The logger to use for Lambda runtime logging + public func run(logger: Logger? = nil) async throws { + let _logger = logger ?? Logger(label: "OpenAPILambdaService") + + let lambda = try OpenAPILambdaHandler(withService: self) + let lambdaRuntime = LambdaRuntime(logger: _logger, body: lambda.handle) + try await lambdaRuntime.run() + } } diff --git a/Sources/OpenAPILambdaTransport.swift b/Sources/OpenAPILambdaTransport.swift index 0dd6dc8..c3ec064 100644 --- a/Sources/OpenAPILambdaTransport.swift +++ b/Sources/OpenAPILambdaTransport.swift @@ -35,12 +35,16 @@ public typealias OpenAPIHandler = @Sendable (HTTPRequest, HTTPBody?, ServerReque ) /// Lambda Transport for OpenAPI generator -public struct OpenAPILambdaTransport: ServerTransport { +public struct OpenAPILambdaTransport: ServerTransport, Sendable { - private var router: OpenAPILambdaRouter + /// The router for the OpenAPILambdaTransport + /// Use this router to register your OpenAPI handlers + /// and add your own route, such as /health + public let router: OpenAPILambdaRouter /// Create a `OpenAPILambdaTransport` with the given `OpenAPILambdaRouter` - public init(router: OpenAPILambdaRouter) { self.router = router } + /// - Parameter router: The router to use for the transport. + init(router: OpenAPILambdaRouter) { self.router = router } /// Registers an HTTP operation handler at the provided path and method. /// - Parameters: diff --git a/Sources/Router/OpenAPILambdaRouter+SimplifiedAPI.swift b/Sources/Router/OpenAPILambdaRouter+SimplifiedAPI.swift new file mode 100644 index 0000000..a53922f --- /dev/null +++ b/Sources/Router/OpenAPILambdaRouter+SimplifiedAPI.swift @@ -0,0 +1,89 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenAPI Lambda open source project +// +// Copyright (c) 2023 Amazon.com, Inc. or its affiliates +// and the Swift OpenAPI Lambda project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift OpenAPI Lambda project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import AWSLambdaEvents +import Foundation +import HTTPTypes +import OpenAPIRuntime + + +// TODO: add more method to simplify usage + +// Current API : + +// try router.add( +// method: .get, +// path: "/health" +// ) { (request: HTTPRequest, body: HTTPBody?, metadata: ServerRequestMetadata) -> (HTTPResponse,HTTPBody?) in +// return (.init(status: .ok), .init("OK")) +// } + +// should be + +// try router.get("/health") { _ in +// "OK" +// } + +extension OpenAPILambdaRouter { + + /// Adds a GET route to the router for the given path. + /// + /// The given handler retruns a String that will be converted to the correct response type. + /// It will return an HTTP 200 response or HTTP 500 if your handler throws and error + /// - Parameters: + /// - path: The path for the route. + /// - handler: The handler to be executed for the route. + /// - Throws: An error if the route cannot be added. + public func get( + _ path: String, + handler: @escaping @Sendable (HTTPRequest, HTTPBody?) async throws -> String + ) throws { + try generic(method: .get, path: path, handler: handler) + } + + /// Adds a POST route to the router for the given path. + /// + /// The given handler retruns a String that will be converted to the correct response type. + /// It will return an HTTP 200 response or HTTP 500 if your handler throws and error + /// - Parameters: + /// - path: The path for the route. + /// - handler: The handler to be executed for the route. + /// - Throws: An error if the route cannot be added. + public func post( + _ path: String, + handler: @escaping @Sendable (HTTPRequest, HTTPBody?) async throws -> String + ) throws { + try generic(method: .post, path: path, handler: handler) + } + + func generic( + method: HTTPRequest.Method, + path: String, + handler: @escaping @Sendable (HTTPRequest, HTTPBody?) async throws -> Response + ) throws { + + let openAPIHandler: OpenAPIHandler = { + (request: HTTPRequest, body: HTTPBody?, metadata: ServerRequestMetadata) -> (HTTPResponse, HTTPBody?) in + do { + let response = try await handler(request, body) + return (.init(status: .ok), .init(response)) + } + catch { + return (.init(status: .internalServerError), nil) + } + } + + try add(method: method, path: path, handler: openAPIHandler) + } +} diff --git a/Sources/Router/OpenAPILambdaRouter.swift b/Sources/Router/OpenAPILambdaRouter.swift index adaa76d..cf4929d 100644 --- a/Sources/Router/OpenAPILambdaRouter.swift +++ b/Sources/Router/OpenAPILambdaRouter.swift @@ -18,18 +18,18 @@ import HTTPTypes /// Errors returned by the router public enum OpenAPILambdaRouterError: Error { - case noRouteForPath(String) + case noRouteForPath(HTTPRequest.Method, String) case noHandlerForPath(String) case noRouteForMethod(HTTPRequest.Method) } /// A router API -public protocol OpenAPILambdaRouter { +public protocol OpenAPILambdaRouter: Sendable { /// add a route for a given HTTP method and path and associate a handler func add(method: HTTPRequest.Method, path: String, handler: @escaping OpenAPIHandler) throws /// Retrieve the handler and path parameter for a given HTTP method and path - func route(method: HTTPRequest.Method, path: String) async throws -> ( + func route(method: HTTPRequest.Method, path: String) throws -> ( OpenAPIHandler, OpenAPILambdaRequestParameters ) } diff --git a/Sources/Router/OpenAPILambdaRouterNode.swift b/Sources/Router/OpenAPILambdaRouterNode.swift index 4f0ed97..b109425 100644 --- a/Sources/Router/OpenAPILambdaRouterNode.swift +++ b/Sources/Router/OpenAPILambdaRouterNode.swift @@ -22,24 +22,24 @@ import HTTPTypes /// - a path element (represented as a `String`), /// - a path parameter (represented as a `String`) /// - a handler, only for leaf nodes (represented as `OpenAPIHandler`) -class Node { +final class Node { let value: NodeValue var children: [String: Node] = [:] /// Default init, create a root node - public init() { value = .root } + init() { value = .root } /// Creates a node for an HTTP method - public init(httpMethod: HTTPRequest.Method) { value = .httpMethod(httpMethod) } + init(httpMethod: HTTPRequest.Method) { value = .httpMethod(httpMethod) } /// Creates a node for a path element - public init(pathElement: String) { value = .pathElement(pathElement) } + init(pathElement: String) { value = .pathElement(pathElement) } /// Creates a node for a path parameter - public init(parameterName: String) { value = .pathParameter(parameterName) } + init(parameterName: String) { value = .pathParameter(parameterName) } /// Creates a node for an OpenAPI handler - public init(handler: @escaping OpenAPIHandler) { value = .handler(handler) } + init(handler: @escaping OpenAPIHandler) { value = .handler(handler) } /// Creates a node for an existing node value private init(value: NodeValue) { self.value = value } @@ -62,8 +62,8 @@ class Node { /// - Throws: /// - URIPathCollectionError.canNotAddChildToHandlerNode when trying to add a child to leaf node of type `.handler` /// - URIPathCollectionError.canNotHaveMultipleParamChilds when trying to add multiple child node of type `.parameter` - func add(pathElement: String) throws -> Node { try add(child: NodeValue.pathElement(pathElement)) } + /// Convenience method to add a child node of type path parameter to this node /// - Parameter: /// - pathParameter: the name of a path parameter. A path parameter is a `{name}` usually found between `/` characters in the URI @@ -72,8 +72,8 @@ class Node { /// - Throws: /// - URIPathCollectionError.canNotAddChildToHandlerNode when trying to add a child to leaf node of type `.handler` /// - URIPathCollectionError.canNotHaveMultipleParamChilds when trying to add multiple child node of type `.parameter` - func add(parameter: String) throws -> Node { try add(child: NodeValue.pathParameter(parameter)) } + /// Convenience method to add a child node of type handler to this node /// - Parameter: /// - handler: a function handler. A handler MUST be a leaf node (it has no children) and is of type `OpenAPIHandler` diff --git a/Sources/Router/OpenAPILambdaRouterTrie.swift b/Sources/Router/OpenAPILambdaRouterTrie.swift index 1d5ca2d..cba2d15 100644 --- a/Sources/Router/OpenAPILambdaRouterTrie.swift +++ b/Sources/Router/OpenAPILambdaRouterTrie.swift @@ -13,20 +13,75 @@ // //===----------------------------------------------------------------------===// import HTTPTypes +import Synchronization /// A Trie router implementation -public struct TrieRouter: OpenAPILambdaRouter { - private let uriPath: URIPathCollection = URIPath() +final class TrieRouter: OpenAPILambdaRouter, CustomStringConvertible { + private let uriPath = Mutex(URIPath()) /// add a route for a given HTTP method and path and associate a handler - public func add(method: HTTPRequest.Method, path: String, handler: @escaping OpenAPIHandler) throws { - try uriPath.add(method: method, path: path, handler: handler) + func add(method: HTTPRequest.Method, path: String, handler: @escaping OpenAPIHandler) throws { + try self.uriPath.withLock { @Sendable in + try $0.add(method: method, path: path, handler: handler) + } } /// Retrieve the handler and path parameter for a given HTTP method and path - public func route(method: HTTPRequest.Method, path: String) async throws -> ( + func route(method: HTTPRequest.Method, path: String) throws -> ( OpenAPIHandler, OpenAPILambdaRequestParameters - ) { try uriPath.find(method: method, path: path) } + ) { + try self.uriPath.withLock { try $0.find(method: method, path: path) } + } + + var description: String { + uriPath.withLock { uriPath in + var routes: [String] = [] + collectRoutes(from: uriPath.root(), method: nil, path: "", parameters: [], routes: &routes) + return routes.joined(separator: "\n") + } + } + + private func collectRoutes( + from node: Node, + method: HTTPRequest.Method?, + path: String, + parameters: [String], + routes: inout [String] + ) { + // If this node has a handler, we found a complete route + if let _ = node.handlerChild(), let method = method { + let paramString = parameters.isEmpty ? "" : " " + parameters.map { "\($0)=value" }.joined(separator: " ") + routes.append("\(method.rawValue) \(path)\(paramString)") + } + + // Traverse all children + for (_, child) in node.children { + switch child.value { + case .httpMethod(let httpMethod): + collectRoutes(from: child, method: httpMethod, path: path, parameters: parameters, routes: &routes) + case .pathElement(let element): + collectRoutes( + from: child, + method: method, + path: path + "/" + element, + parameters: parameters, + routes: &routes + ) + case .pathParameter(let param): + var newParams = parameters + newParams.append(param) + collectRoutes( + from: child, + method: method, + path: path + "/{\(param)}", + parameters: newParams, + routes: &routes + ) + case .handler, .root: + collectRoutes(from: child, method: method, path: path, parameters: parameters, routes: &routes) + } + } + } } enum URIPathCollectionError: Error { @@ -46,7 +101,7 @@ protocol URIPathCollection { /// Example : /// an URI of GET /stocks/{symbol} will generate a tree `root -> GET -> stocks -> symbol` struct URIPath: URIPathCollection { - private var _root = Node() + private let _root = Node() func root() -> Node { self._root } @@ -126,7 +181,7 @@ struct URIPath: URIPathCollection { currentNode = child } else { - throw OpenAPILambdaRouterError.noRouteForPath(path) + throw OpenAPILambdaRouterError.noRouteForPath(method, path) } } } diff --git a/Tests/OpenAPILambdaTests/Router/RouterGraphTest.swift b/Tests/OpenAPILambdaTests/Router/RouterGraphTest.swift index 610c736..c26fe9e 100644 --- a/Tests/OpenAPILambdaTests/Router/RouterGraphTest.swift +++ b/Tests/OpenAPILambdaTests/Router/RouterGraphTest.swift @@ -14,13 +14,10 @@ //===----------------------------------------------------------------------===// import HTTPTypes import OpenAPIRuntime +import Testing @testable import OpenAPILambda -// only run unit tests on Swift 6.x -#if swift(>=6.0) -import Testing - struct RouterGraphTests { @Test("Path with no parameters") func testPathNoParams() async throws { @@ -323,9 +320,8 @@ struct RouterGraphTests { //when #expect(throws: Never.self) { try graph.find(method: method, path: pathToTest) } - let (handler, metadata) = try graph.find(method: method, path: pathToTest) + let (_, metadata) = try graph.find(method: method, path: pathToTest) #expect(metadata.count == 0) - #expect(handler != nil) } @Test( @@ -346,11 +342,10 @@ struct RouterGraphTests { //when #expect(throws: Never.self) { - let (handler, metadata) = try graph.find(method: method, path: pathToTest) + let (_, metadata) = try graph.find(method: method, path: pathToTest) // then (we can not test if the query string param have been decoded, that's the job of the openapi runtime.) #expect(metadata.count == 1) - #expect(handler != nil) } } @@ -365,9 +360,8 @@ struct RouterGraphTests { //when #expect(throws: Never.self) { try graph.find(method: method, path: pathToTest) } - let (handler, metadata) = try graph.find(method: method, path: pathToTest) + let (_, metadata) = try graph.find(method: method, path: pathToTest) #expect(metadata.count == 1) - #expect(handler != nil) } @Test("Find handler error 1") @@ -439,5 +433,6 @@ struct RouterGraphTests { return graph } + // TODO: add a test to check what happens when two methods + identiacal paths with two different handlers are added + } -#endif diff --git a/Tests/OpenAPILambdaTests/Router/RouterNodeTest.swift b/Tests/OpenAPILambdaTests/Router/RouterNodeTest.swift index dee9a86..23ff5c4 100644 --- a/Tests/OpenAPILambdaTests/Router/RouterNodeTest.swift +++ b/Tests/OpenAPILambdaTests/Router/RouterNodeTest.swift @@ -14,13 +14,10 @@ //===----------------------------------------------------------------------===// import HTTPTypes import OpenAPIRuntime +import Testing @testable import OpenAPILambda -// only run unit tests on Swift 6.x -#if swift(>=6.0) -import Testing - struct RouterNodeTests { @Test("First node is root") func testFirstNodeIsRoot() throws { @@ -212,4 +209,3 @@ struct RouterNodeTests { } } -#endif diff --git a/assets/swift-openapi-lambda-v0x.gif b/assets/swift-openapi-lambda-v0x.gif new file mode 100644 index 0000000..7ff4383 Binary files /dev/null and b/assets/swift-openapi-lambda-v0x.gif differ diff --git a/assets/swift-openapi-lambda.gif b/assets/swift-openapi-lambda.gif index 7ff4383..5addd38 100644 Binary files a/assets/swift-openapi-lambda.gif and b/assets/swift-openapi-lambda.gif differ