diff --git a/Sources/AWSLambdaRuntime/Lambda+LocalServer.swift b/Sources/AWSLambdaRuntime/Lambda+LocalServer.swift index 45468c97..c3fa2e5d 100644 --- a/Sources/AWSLambdaRuntime/Lambda+LocalServer.swift +++ b/Sources/AWSLambdaRuntime/Lambda+LocalServer.swift @@ -21,7 +21,7 @@ import NIOHTTP1 import NIOPosix import Synchronization -// This functionality is designed for local testing hence being a #if DEBUG flag. +// This functionality is designed for local testing when the LocalServerSupport trait is enabled. // For example: // try Lambda.withLocalServer { @@ -42,18 +42,24 @@ extension Lambda { /// Execute code in the context of a mock Lambda server. /// /// - parameters: + /// - host: the hostname or IP address to listen on + /// - port: the TCP port to listen to /// - invocationEndpoint: The endpoint to post events to. /// - body: Code to run within the context of the mock server. Typically this would be a Lambda.run function call. /// - /// - note: This API is designed strictly for local testing and is behind a DEBUG flag + /// - note: This API is designed strictly for local testing when the LocalServerSupport trait is enabled. @usableFromInline static func withLocalServer( + host: String, + port: Int, invocationEndpoint: String? = nil, logger: Logger, _ body: sending @escaping () async throws -> Void ) async throws { do { try await LambdaHTTPServer.withLocalServer( + host: host, + port: port, invocationEndpoint: invocationEndpoint, logger: logger ) { @@ -112,9 +118,9 @@ internal struct LambdaHTTPServer { } static func withLocalServer( + host: String, + port: Int, invocationEndpoint: String?, - host: String = "127.0.0.1", - port: Int = 7000, eventLoopGroup: MultiThreadedEventLoopGroup = .singleton, logger: Logger, _ closure: sending @escaping () async throws -> Result diff --git a/Sources/AWSLambdaRuntime/LambdaRuntime.swift b/Sources/AWSLambdaRuntime/LambdaRuntime.swift index 906b1f1b..d3a5d5bf 100644 --- a/Sources/AWSLambdaRuntime/LambdaRuntime.swift +++ b/Sources/AWSLambdaRuntime/LambdaRuntime.swift @@ -124,15 +124,23 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb } else { #if LocalServerSupport + // we're not running on Lambda and we're compiled in DEBUG mode, // let's start a local server for testing + + let host = Lambda.env("LOCAL_LAMBDA_HOST") ?? "127.0.0.1" + let port = Lambda.env("LOCAL_LAMBDA_PORT").flatMap(Int.init) ?? 7000 + let endpoint = Lambda.env("LOCAL_LAMBDA_INVOCATION_ENDPOINT") + try await Lambda.withLocalServer( - invocationEndpoint: Lambda.env("LOCAL_LAMBDA_SERVER_INVOCATION_ENDPOINT"), + host: host, + port: port, + invocationEndpoint: endpoint, logger: self.logger ) { try await LambdaRuntimeClient.withRuntimeClient( - configuration: .init(ip: "127.0.0.1", port: 7000), + configuration: .init(ip: host, port: port), eventLoop: self.eventLoop, logger: self.logger ) { runtimeClient in @@ -144,7 +152,7 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb } } #else - // in release mode, we can't start a local server because the local server code is not compiled. + // When the LocalServerSupport trait is disabled, we can't start a local server because the local server code is not compiled. throw LambdaRuntimeError(code: .missingLambdaRuntimeAPIEnvironmentVariable) #endif } diff --git a/Tests/AWSLambdaRuntimeTests/LambdaLocalServerTests.swift b/Tests/AWSLambdaRuntimeTests/LambdaLocalServerTests.swift new file mode 100644 index 00000000..1004d919 --- /dev/null +++ b/Tests/AWSLambdaRuntimeTests/LambdaLocalServerTests.swift @@ -0,0 +1,94 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2025 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 Logging +import NIOCore +import NIOPosix +import Testing + +@testable import AWSLambdaRuntime + +extension LambdaRuntimeTests { + + @Test("Local server respects LOCAL_LAMBDA_PORT environment variable") + func testLocalServerCustomPort() async throws { + let customPort = 8080 + + // Set environment variable + setenv("LOCAL_LAMBDA_PORT", "\(customPort)", 1) + defer { unsetenv("LOCAL_LAMBDA_PORT") } + + let result = try? await withThrowingTaskGroup(of: Bool.self) { group in + + // start a local lambda + local server on custom port + group.addTask { + // Create a simple handler + struct TestHandler: StreamingLambdaHandler { + func handle( + _ event: ByteBuffer, + responseWriter: some LambdaResponseStreamWriter, + context: LambdaContext + ) async throws { + try await responseWriter.write(ByteBuffer(string: "test")) + try await responseWriter.finish() + } + } + + // create the Lambda Runtime + let runtime = LambdaRuntime( + handler: TestHandler(), + logger: Logger(label: "test", factory: { _ in SwiftLogNoOpLogHandler() }) + ) + + // Start runtime + try await runtime._run() + + // we reach this line when the group is cancelled + return false + } + + // start a client to check if something responds on the custom port + group.addTask { + // Give server time to start + try await Task.sleep(for: .milliseconds(100)) + + // Verify server is listening on custom port + return try await isPortResponding(host: "127.0.0.1", port: customPort) + } + + let first = try await group.next() + group.cancelAll() + return first ?? false + + } + + #expect(result == true) + } + + private func isPortResponding(host: String, port: Int) async throws -> Bool { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + + let bootstrap = ClientBootstrap(group: group) + + do { + let channel = try await bootstrap.connect(host: host, port: port).get() + try await channel.close().get() + try await group.shutdownGracefully() + return true + } catch { + try await group.shutdownGracefully() + return false + } + } +} diff --git a/Tests/AWSLambdaRuntimeTests/LambdaRuntime+ServiceLifeCycle.swift b/Tests/AWSLambdaRuntimeTests/LambdaRuntime+ServiceLifeCycle.swift index 0954eb10..3971c261 100644 --- a/Tests/AWSLambdaRuntimeTests/LambdaRuntime+ServiceLifeCycle.swift +++ b/Tests/AWSLambdaRuntimeTests/LambdaRuntime+ServiceLifeCycle.swift @@ -18,8 +18,7 @@ import ServiceLifecycle import Testing import Logging -@Suite -struct LambdaRuntimeServiceLifecycleTests { +extension LambdaRuntimeTests { @Test @available(LambdaSwift 2.0, *) func testLambdaRuntimeGracefulShutdown() async throws { diff --git a/Tests/AWSLambdaRuntimeTests/LambdaRuntimeTests.swift b/Tests/AWSLambdaRuntimeTests/LambdaRuntimeTests.swift index 17c4cbf0..0fadf5d2 100644 --- a/Tests/AWSLambdaRuntimeTests/LambdaRuntimeTests.swift +++ b/Tests/AWSLambdaRuntimeTests/LambdaRuntimeTests.swift @@ -20,7 +20,7 @@ import Testing @testable import AWSLambdaRuntime -@Suite("LambdaRuntimeTests") +@Suite(.serialized) struct LambdaRuntimeTests { @Test("LambdaRuntime can only be run once") diff --git a/Tests/AWSLambdaRuntimeTests/MockLambdaServer.swift b/Tests/AWSLambdaRuntimeTests/MockLambdaServer.swift index 8429f7fd..fa84ed75 100644 --- a/Tests/AWSLambdaRuntimeTests/MockLambdaServer.swift +++ b/Tests/AWSLambdaRuntimeTests/MockLambdaServer.swift @@ -60,7 +60,7 @@ final class MockLambdaServer { init( behavior: Behavior, host: String = "127.0.0.1", - port: Int = 7000, + port: Int = 0, keepAlive: Bool = true, eventLoopGroup: MultiThreadedEventLoopGroup ) { diff --git a/readme.md b/readme.md index 2366b53e..75e17bcb 100644 --- a/readme.md +++ b/readme.md @@ -464,16 +464,22 @@ curl -v --header "Content-Type:\ application/json" --data @events/create-session * Connection #0 to host 127.0.0.1 left intact {"statusCode":200,"isBase64Encoded":false,"body":"...","headers":{"Access-Control-Allow-Origin":"*","Content-Type":"application\/json; charset=utf-8","Access-Control-Allow-Headers":"*"}} ``` -### Modifying the local endpoint +### Modifying the local server URI -By default, when using the local Lambda server, it listens on the `/invoke` endpoint. +By default, when using the local Lambda server during your tests, it listens on `http://127.0.0.1:7000/invoke`. -Some testing tools, such as the [AWS Lambda runtime interface emulator](https://docs.aws.amazon.com/lambda/latest/dg/images-test.html), require a different endpoint. In that case, you can use the `LOCAL_LAMBDA_SERVER_INVOCATION_ENDPOINT` environment variable to force the runtime to listen on a different endpoint. +Some testing tools, such as the [AWS Lambda runtime interface emulator](https://docs.aws.amazon.com/lambda/latest/dg/images-test.html), require a different endpoint, the port might be used, or you may want to bind a specific IP address. + +In these cases, you can use three environment variables to control the local server: + +- Set `LOCAL_LAMBDA_HOST` to configure the local server to listen on a different TCP address. +- Set `LOCAL_LAMBDA_PORT` to configure the local server to listen on a different TCP port. +- Set `LOCAL_LAMBDA_INVOCATION_ENDPOINT` to force the local server to listen on a different endpoint. Example: ```sh -LOCAL_LAMBDA_SERVER_INVOCATION_ENDPOINT=/2015-03-31/functions/function/invocations swift run +LOCAL_LAMBDA_PORT=8080 LOCAL_LAMBDA_INVOCATION_ENDPOINT=/2015-03-31/functions/function/invocations swift run ``` ## Deploying your Swift Lambda functions