mirror of
https://github.com/swift-server/swift-aws-lambda-runtime.git
synced 2026-06-02 07:27:33 +00:00
Revert streaming codable handler change and propose it as an example instead of an handler API. **Motivation:** I made a mistake when submitting this PR https://github.com/swift-server/swift-aws-lambda-runtime/pull/532 It provides a Streaming+Codable handler that conveniently allows developers to write handlers with `Codable` events for streaming functions. This is a mistake for three reasons: - This is the only handler that assumes a Lamba Event structure as input. I added a minimal `FunctionUrlRequest` and `FunctionURLResponse` to avoid importing the AWS Lambda Events library. It is the first handler to be event-specific. I don't think the runtime should introduce event specific code. - The handler only works when Lambda functions are exposed through Function URLs. Streaming functions can also be invoke by API or CLI. - The handler hides `FunctionURLRequest` details (HTTP headers, query parameters, etc.) from developers Developers were unaware they were trading flexibility for convenience The lack of clear documentation about these limitations led to incorrect usage patterns and frustrated developers who needed full request control or were using other invocation methods. **Modifications:** - Removed the Streaming+Codable API from the library - Moved the Streaming+Codable code to an example - Added prominent warning section in the example README explaining the limitations - Clarified when to use Streaming+Codable vs ByteBuffer approaches - Added decision rule framework to help developers choose the right approach **Result:** The only API provided by the library to use Streaming Lambda functions is exposing the raw `ByteBuffer` as input, there is no more `Codable` handler for Streaming functions available in the API. I kept the `Streaming+Codable` code an example. After this change, developers have clear guidance on when to use each streaming approach: - Use streaming codable for Function URL + JSON payload + no request details needed - Use ByteBuffer StreamingLambdaHandler for full control, other invocation methods, or request metadata access This prevents misuse of the API and sets proper expectations about the handler's capabilities and limitations, leading to better developer experience and fewer integration issues.
342 lines
12 KiB
Swift
342 lines
12 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
//
|
|
// 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 Synchronization
|
|
import Testing
|
|
|
|
@testable import AWSLambdaRuntime
|
|
@testable import StreamingCodable
|
|
|
|
#if canImport(FoundationEssentials)
|
|
import FoundationEssentials
|
|
#else
|
|
import Foundation
|
|
#endif
|
|
|
|
@Suite("Streaming Codable Lambda Handler Tests")
|
|
struct LambdaStreamingFromEventTests {
|
|
|
|
// MARK: - Test Data Structures
|
|
|
|
struct TestEvent: Decodable, Equatable {
|
|
let message: String
|
|
let count: Int
|
|
let delay: Int?
|
|
}
|
|
|
|
struct SimpleEvent: Decodable, Equatable {
|
|
let value: String
|
|
}
|
|
|
|
// MARK: - Mock Response Writer
|
|
|
|
actor MockResponseWriter: LambdaResponseStreamWriter {
|
|
private var writtenBuffers: [ByteBuffer] = []
|
|
private var isFinished = false
|
|
private var writeAndFinishCalled = false
|
|
|
|
func write(_ buffer: ByteBuffer, hasCustomHeaders: Bool = false) async throws {
|
|
guard !isFinished else {
|
|
throw MockError.writeAfterFinish
|
|
}
|
|
writtenBuffers.append(buffer)
|
|
}
|
|
|
|
func finish() async throws {
|
|
guard !isFinished else {
|
|
throw MockError.alreadyFinished
|
|
}
|
|
isFinished = true
|
|
}
|
|
|
|
func writeAndFinish(_ buffer: ByteBuffer) async throws {
|
|
try await write(buffer)
|
|
try await finish()
|
|
writeAndFinishCalled = true
|
|
}
|
|
|
|
// Test helpers
|
|
func getWrittenData() -> [String] {
|
|
writtenBuffers.compactMap { buffer in
|
|
buffer.getString(at: buffer.readerIndex, length: buffer.readableBytes)
|
|
}
|
|
}
|
|
|
|
func getFinished() -> Bool {
|
|
isFinished
|
|
}
|
|
|
|
func getWriteAndFinishCalled() -> Bool {
|
|
writeAndFinishCalled
|
|
}
|
|
}
|
|
|
|
enum MockError: Error {
|
|
case writeAfterFinish
|
|
case alreadyFinished
|
|
case decodingFailed
|
|
case handlerError
|
|
}
|
|
|
|
// MARK: - Test StreamingFromEventClosureHandler
|
|
|
|
@Test("StreamingFromEventClosureHandler handles decoded events correctly")
|
|
func testStreamingFromEventClosureHandler() async throws {
|
|
let responseWriter = MockResponseWriter()
|
|
let context = LambdaContext.makeTest()
|
|
|
|
let handler = StreamingFromEventClosureHandler<TestEvent> { event, writer, context in
|
|
let message = "Received: \(event.message) (count: \(event.count))"
|
|
try await writer.writeAndFinish(ByteBuffer(string: message))
|
|
}
|
|
|
|
let testEvent = TestEvent(message: "Hello", count: 42, delay: nil)
|
|
|
|
try await handler.handle(testEvent, responseWriter: responseWriter, context: context)
|
|
|
|
let writtenData = await responseWriter.getWrittenData()
|
|
let isFinished = await responseWriter.getFinished()
|
|
|
|
#expect(writtenData == ["Received: Hello (count: 42)"])
|
|
#expect(isFinished == true)
|
|
}
|
|
|
|
@Test("StreamingFromEventClosureHandler can stream multiple responses")
|
|
func testStreamingMultipleResponses() async throws {
|
|
let responseWriter = MockResponseWriter()
|
|
let context = LambdaContext.makeTest()
|
|
|
|
let handler = StreamingFromEventClosureHandler<TestEvent> { event, writer, context in
|
|
for i in 1...event.count {
|
|
try await writer.write(ByteBuffer(string: "\(i): \(event.message)\n"))
|
|
}
|
|
try await writer.finish()
|
|
}
|
|
|
|
let testEvent = TestEvent(message: "Test", count: 3, delay: nil)
|
|
|
|
try await handler.handle(testEvent, responseWriter: responseWriter, context: context)
|
|
|
|
let writtenData = await responseWriter.getWrittenData()
|
|
let isFinished = await responseWriter.getFinished()
|
|
|
|
#expect(writtenData == ["1: Test\n", "2: Test\n", "3: Test\n"])
|
|
#expect(isFinished == true)
|
|
}
|
|
|
|
// MARK: - Test StreamingLambdaCodableAdapter
|
|
|
|
@Test("StreamingLambdaCodableAdapter decodes JSON and calls handler")
|
|
func testStreamingLambdaCodableAdapter() async throws {
|
|
let responseWriter = MockResponseWriter()
|
|
let context = LambdaContext.makeTest()
|
|
|
|
let closureHandler = StreamingFromEventClosureHandler<SimpleEvent> { event, writer, context in
|
|
try await writer.writeAndFinish(ByteBuffer(string: "Echo: \(event.value)"))
|
|
}
|
|
|
|
var adapter = StreamingLambdaCodableAdapter(
|
|
decoder: LambdaJSONEventDecoder(JSONDecoder()),
|
|
handler: closureHandler
|
|
)
|
|
|
|
let jsonData = #"{"value": "test message"}"#
|
|
let inputBuffer = ByteBuffer(string: jsonData)
|
|
|
|
try await adapter.handle(inputBuffer, responseWriter: responseWriter, context: context)
|
|
|
|
let writtenData = await responseWriter.getWrittenData()
|
|
let isFinished = await responseWriter.getFinished()
|
|
|
|
#expect(writtenData == ["Echo: test message"])
|
|
#expect(isFinished == true)
|
|
}
|
|
|
|
@Test("StreamingLambdaCodableAdapter handles JSON decoding errors")
|
|
func testStreamingLambdaCodableAdapterDecodingError() async throws {
|
|
let responseWriter = MockResponseWriter()
|
|
let context = LambdaContext.makeTest()
|
|
|
|
let closureHandler = StreamingFromEventClosureHandler<SimpleEvent> { event, writer, context in
|
|
try await writer.writeAndFinish(ByteBuffer(string: "Should not reach here"))
|
|
}
|
|
|
|
var adapter = StreamingLambdaCodableAdapter(
|
|
decoder: LambdaJSONEventDecoder(JSONDecoder()),
|
|
handler: closureHandler
|
|
)
|
|
|
|
let invalidJsonData = #"{"invalid": "json structure"}"#
|
|
let inputBuffer = ByteBuffer(string: invalidJsonData)
|
|
|
|
await #expect(throws: DecodingError.self) {
|
|
try await adapter.handle(inputBuffer, responseWriter: responseWriter, context: context)
|
|
}
|
|
|
|
let writtenData = await responseWriter.getWrittenData()
|
|
#expect(writtenData.isEmpty)
|
|
}
|
|
|
|
@Test("StreamingLambdaCodableAdapter with convenience JSON initializer")
|
|
func testStreamingLambdaCodableAdapterJSONConvenience() async throws {
|
|
let responseWriter = MockResponseWriter()
|
|
let context = LambdaContext.makeTest()
|
|
|
|
let closureHandler = StreamingFromEventClosureHandler<TestEvent> { event, writer, context in
|
|
try await writer.write(ByteBuffer(string: "Message: \(event.message)\n"))
|
|
try await writer.write(ByteBuffer(string: "Count: \(event.count)\n"))
|
|
try await writer.finish()
|
|
}
|
|
|
|
var adapter = StreamingLambdaCodableAdapter(handler: closureHandler)
|
|
|
|
let jsonData = #"{"message": "Hello World", "count": 5, "delay": 100}"#
|
|
let inputBuffer = ByteBuffer(string: jsonData)
|
|
|
|
try await adapter.handle(inputBuffer, responseWriter: responseWriter, context: context)
|
|
|
|
let writtenData = await responseWriter.getWrittenData()
|
|
let isFinished = await responseWriter.getFinished()
|
|
|
|
#expect(writtenData == ["Message: Hello World\n", "Count: 5\n"])
|
|
#expect(isFinished == true)
|
|
}
|
|
|
|
// MARK: - Test Error Handling
|
|
|
|
@Test("Handler errors are properly propagated")
|
|
func testHandlerErrorPropagation() async throws {
|
|
let responseWriter = MockResponseWriter()
|
|
let context = LambdaContext.makeTest()
|
|
|
|
let closureHandler = StreamingFromEventClosureHandler<SimpleEvent> { event, writer, context in
|
|
throw MockError.handlerError
|
|
}
|
|
|
|
var adapter = StreamingLambdaCodableAdapter(
|
|
decoder: LambdaJSONEventDecoder(JSONDecoder()),
|
|
handler: closureHandler
|
|
)
|
|
|
|
let jsonData = #"{"value": "test"}"#
|
|
let inputBuffer = ByteBuffer(string: jsonData)
|
|
|
|
await #expect(throws: MockError.self) {
|
|
try await adapter.handle(inputBuffer, responseWriter: responseWriter, context: context)
|
|
}
|
|
}
|
|
|
|
// MARK: - Test Custom Handler Implementation
|
|
|
|
struct CustomStreamingHandler: StreamingLambdaHandlerWithEvent {
|
|
typealias Event = TestEvent
|
|
|
|
func handle(
|
|
_ event: Event,
|
|
responseWriter: some LambdaResponseStreamWriter,
|
|
context: LambdaContext
|
|
) async throws {
|
|
context.logger.trace("Processing event with message: \(event.message)")
|
|
|
|
let response = "Processed: \(event.message) with count \(event.count)"
|
|
try await responseWriter.writeAndFinish(ByteBuffer(string: response))
|
|
}
|
|
}
|
|
|
|
@Test("Custom StreamingLambdaHandlerWithEvent implementation works")
|
|
func testCustomStreamingHandler() async throws {
|
|
let responseWriter = MockResponseWriter()
|
|
let context = LambdaContext.makeTest()
|
|
|
|
let handler = CustomStreamingHandler()
|
|
let testEvent = TestEvent(message: "Custom Handler Test", count: 10, delay: nil)
|
|
|
|
try await handler.handle(testEvent, responseWriter: responseWriter, context: context)
|
|
|
|
let writtenData = await responseWriter.getWrittenData()
|
|
let isFinished = await responseWriter.getFinished()
|
|
|
|
#expect(writtenData == ["Processed: Custom Handler Test with count 10"])
|
|
#expect(isFinished == true)
|
|
}
|
|
|
|
@Test("Custom handler with adapter works end-to-end")
|
|
func testCustomHandlerWithAdapter() async throws {
|
|
let responseWriter = MockResponseWriter()
|
|
let context = LambdaContext.makeTest()
|
|
|
|
let customHandler = CustomStreamingHandler()
|
|
var adapter = StreamingLambdaCodableAdapter(handler: customHandler)
|
|
|
|
let jsonData = #"{"message": "End-to-end test", "count": 7}"#
|
|
let inputBuffer = ByteBuffer(string: jsonData)
|
|
|
|
try await adapter.handle(inputBuffer, responseWriter: responseWriter, context: context)
|
|
|
|
let writtenData = await responseWriter.getWrittenData()
|
|
let isFinished = await responseWriter.getFinished()
|
|
|
|
#expect(writtenData == ["Processed: End-to-end test with count 7"])
|
|
#expect(isFinished == true)
|
|
}
|
|
|
|
// MARK: - Test Background Work Simulation
|
|
|
|
@Test("Handler can perform background work after streaming")
|
|
func testBackgroundWorkAfterStreaming() async throws {
|
|
let responseWriter = MockResponseWriter()
|
|
let context = LambdaContext.makeTest()
|
|
|
|
let backgroundWorkCompleted = Atomic<Bool>(false)
|
|
|
|
let handler = StreamingFromEventClosureHandler<SimpleEvent> { event, writer, context in
|
|
// Send response first
|
|
try await writer.writeAndFinish(ByteBuffer(string: "Response: \(event.value)"))
|
|
|
|
// Simulate background work
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
backgroundWorkCompleted.store(true, ordering: .relaxed)
|
|
}
|
|
|
|
let testEvent = SimpleEvent(value: "background test")
|
|
|
|
try await handler.handle(testEvent, responseWriter: responseWriter, context: context)
|
|
|
|
let writtenData = await responseWriter.getWrittenData()
|
|
let isFinished = await responseWriter.getFinished()
|
|
let writeAndFinishCalled = await responseWriter.getWriteAndFinishCalled()
|
|
|
|
#expect(writtenData == ["Response: background test"])
|
|
#expect(isFinished == true)
|
|
#expect(writeAndFinishCalled == true)
|
|
#expect(backgroundWorkCompleted.load(ordering: .relaxed) == true)
|
|
}
|
|
}
|
|
|
|
// MARK: - Test Helpers
|
|
|
|
extension LambdaContext {
|
|
static func makeTest() -> LambdaContext {
|
|
LambdaContext.__forTestsOnly(
|
|
requestID: "test-request-id",
|
|
traceID: "test-trace-id",
|
|
invokedFunctionARN: "arn:aws:lambda:us-east-1:123456789012:function:test",
|
|
timeout: .seconds(30),
|
|
logger: Logger(label: "test")
|
|
)
|
|
}
|
|
}
|