diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4c8c4f6..277a6f9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,10 +20,8 @@ kotlin-date-time = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version. kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinest-test" } junit = { group = "junit", name = "junit", version.ref = "junit" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } -ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" } -ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" } -ktor-server-tests = { module = "io.ktor:ktor-server-tests-jvm", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } diff --git a/lambda-runtime/build.gradle.kts b/lambda-runtime/build.gradle.kts index 3138743..6117a00 100644 --- a/lambda-runtime/build.gradle.kts +++ b/lambda-runtime/build.gradle.kts @@ -1,8 +1,10 @@ +import dev.mokkery.MockMode + plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.mokkery) - alias(libs.plugins.allopen) + alias(libs.plugins.kotlinx.resources) } kotlin { @@ -29,15 +31,15 @@ kotlin { } nativeTest.dependencies { + implementation(projects.lambdaEvents) implementation(libs.kotlin.test) + implementation(libs.kotlin.coroutines.test) + implementation(libs.ktor.client.mock) + implementation(libs.kotlinx.resources) } } } -fun isTestingTask(name: String) = name.endsWith("Test") -val isTesting = gradle.startParameter.taskNames.any(::isTestingTask) - -if (isTesting) allOpen { - annotation("kotlin.Metadata") +mokkery { + defaultMockMode.set(MockMode.autoUnit) } - diff --git a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/Ext.kt b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/Ext.kt similarity index 65% rename from lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/Ext.kt rename to lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/Ext.kt index 28941e7..b872ce4 100644 --- a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/Ext.kt +++ b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/Ext.kt @@ -1,4 +1,4 @@ -package io.github.trueangle.knative.lambda.runtime.log +package io.github.trueangle.knative.lambda.runtime internal fun Throwable.prettyPrint(includeStackTrace: Boolean = true) = buildString { append("An exception occurred:\n") @@ -9,4 +9,6 @@ internal fun Throwable.prettyPrint(includeStackTrace: Boolean = true) = buildStr append("Stack Trace:\n") append(stackTraceToString()) } -} \ No newline at end of file +} + +internal fun unsafeLazy(initializer: () -> T): Lazy = lazy(LazyThreadSafetyMode.NONE, initializer) \ No newline at end of file diff --git a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/LambdaEnvironment.kt b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/LambdaEnvironment.kt index 574dcee..fb8c581 100644 --- a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/LambdaEnvironment.kt +++ b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/LambdaEnvironment.kt @@ -11,24 +11,44 @@ import io.github.trueangle.knative.lambda.runtime.ReservedRuntimeEnvironmentVari import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.toKString import platform.posix.getenv +import kotlin.system.exitProcess -@OptIn(ExperimentalForeignApi::class) @PublishedApi -internal object LambdaEnvironment { - val FUNCTION_MEMORY_SIZE = getenv(AWS_LAMBDA_FUNCTION_MEMORY_SIZE)?.toKString()?.toIntOrNull() ?: 128 - val LOG_GROUP_NAME: String = getenv(AWS_LAMBDA_LOG_GROUP_NAME)?.toKString().orEmpty() - val LOG_STREAM_NAME: String = getenv(AWS_LAMBDA_LOG_STREAM_NAME)?.toKString().orEmpty() - val LAMBDA_LOG_LEVEL: String? = getenv(AWS_LAMBDA_LOG_LEVEL)?.toKString() - val LAMBDA_LOG_FORMAT: String? = getenv(AWS_LAMBDA_LOG_FORMAT)?.toKString() - val FUNCTION_NAME: String = getenv(AWS_LAMBDA_FUNCTION_NAME)?.toKString().orEmpty() - val FUNCTION_VERSION: String = getenv(AWS_LAMBDA_FUNCTION_VERSION)?.toKString().orEmpty() - - val RUNTIME_API: String = requireNotNull(getenv(AWS_LAMBDA_RUNTIME_API)?.toKString()) { - "Can't find AWS_LAMBDA_RUNTIME_API env variable" +internal open class LambdaEnvironment { + // open due to Mokkery limits + open fun terminate(): Nothing = exitProcess(1) + + @OptIn(ExperimentalForeignApi::class) + @PublishedApi + internal companion object Variables { + val FUNCTION_MEMORY_SIZE by unsafeLazy { + getenv(AWS_LAMBDA_FUNCTION_MEMORY_SIZE)?.toKString()?.toIntOrNull() ?: 128 + } + val LOG_GROUP_NAME by unsafeLazy { + getenv(AWS_LAMBDA_LOG_GROUP_NAME)?.toKString().orEmpty() + } + val LOG_STREAM_NAME by unsafeLazy { + getenv(AWS_LAMBDA_LOG_STREAM_NAME)?.toKString().orEmpty() + } + val LAMBDA_LOG_LEVEL by unsafeLazy { + getenv(AWS_LAMBDA_LOG_LEVEL)?.toKString() ?: "INFO" + } + val LAMBDA_LOG_FORMAT by unsafeLazy { + getenv(AWS_LAMBDA_LOG_FORMAT)?.toKString() ?: "TEXT" + } + val FUNCTION_NAME by unsafeLazy { + getenv(AWS_LAMBDA_FUNCTION_NAME)?.toKString().orEmpty() + } + val FUNCTION_VERSION by unsafeLazy { + getenv(AWS_LAMBDA_FUNCTION_VERSION)?.toKString().orEmpty() + } + val RUNTIME_API by unsafeLazy { + getenv(AWS_LAMBDA_RUNTIME_API)?.toKString() + } } } -private object ReservedRuntimeEnvironmentVariables { +internal object ReservedRuntimeEnvironmentVariables { /** * The handler location configured on the function. */ diff --git a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/LambdaRuntime.kt b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/LambdaRuntime.kt index 4c2db9a..e8541c7 100644 --- a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/LambdaRuntime.kt +++ b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/LambdaRuntime.kt @@ -3,12 +3,20 @@ package io.github.trueangle.knative.lambda.runtime import io.github.trueangle.knative.lambda.runtime.LambdaEnvironmentException.NonRecoverableStateException import io.github.trueangle.knative.lambda.runtime.api.Context import io.github.trueangle.knative.lambda.runtime.api.LambdaClient +import io.github.trueangle.knative.lambda.runtime.api.LambdaClientImpl import io.github.trueangle.knative.lambda.runtime.handler.LambdaBufferedHandler import io.github.trueangle.knative.lambda.runtime.handler.LambdaHandler import io.github.trueangle.knative.lambda.runtime.handler.LambdaStreamHandler import io.github.trueangle.knative.lambda.runtime.log.KtorLogger +import io.github.trueangle.knative.lambda.runtime.log.LambdaLogger import io.github.trueangle.knative.lambda.runtime.log.Log +import io.github.trueangle.knative.lambda.runtime.log.debug +import io.github.trueangle.knative.lambda.runtime.log.error +import io.github.trueangle.knative.lambda.runtime.log.fatal +import io.github.trueangle.knative.lambda.runtime.log.info +import io.github.trueangle.knative.lambda.runtime.log.warn import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.curl.Curl import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation @@ -22,13 +30,20 @@ import io.ktor.utils.io.writeStringUtf8 import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json -import kotlin.system.exitProcess object LambdaRuntime { @OptIn(ExperimentalSerializationApi::class) internal val json = Json { explicitNulls = false } - private val httpClient = HttpClient(Curl) { + inline fun run(crossinline initHandler: () -> LambdaHandler) = runBlocking { + val curlHttpClient = createHttpClient(Curl.create()) + val lambdaClient = LambdaClientImpl(curlHttpClient) + + Runner(client = lambdaClient, log = Log).run(false, initHandler) + } + + @PublishedApi + internal fun createHttpClient(engine: HttpClientEngine) = HttpClient(engine) { install(HttpTimeout) install(ContentNegotiation) { json(json) } install(Logging) { @@ -39,86 +54,93 @@ object LambdaRuntime { filter { !it.headers.contains("Lambda-Runtime-Function-Response-Mode", "streaming") } } } +} - @PublishedApi - internal val client = LambdaClient(httpClient) - - inline fun run(crossinline initHandler: () -> LambdaHandler) = runBlocking { +@PublishedApi +internal class Runner( + val client: LambdaClient, + val log: LambdaLogger, + val env: LambdaEnvironment = LambdaEnvironment() +) { + suspend inline fun run(singleEventMode: Boolean = false, crossinline initHandler: () -> LambdaHandler) { val handler = try { - Log.info("Initializing Kotlin Native Lambda Runtime") + log.info("Initializing Kotlin Native Lambda Runtime") initHandler() } catch (e: Exception) { - Log.fatal(e) + log.fatal(e) client.reportError(e.asInitError()) - exitProcess(1) + + env.terminate() } val handlerName = handler::class.simpleName val inputTypeInfo = typeInfo() val outputTypeInfo = typeInfo() - while (true) { + var shouldExit = false + while (!shouldExit) { try { - Log.info("Runtime is ready for a new event") + log.info("Runtime is ready for a new event") val (event, context) = client.retrieveNextEvent(inputTypeInfo) - with(Log) { + with(log) { setContext(context) debug(event) debug(context) + info("$handlerName invocation started") } - Log.info("$handlerName invocation started") - if (handler is LambdaStreamHandler) { val response = streamingResponse { handler.handleRequest(event, it, context) } - Log.info("$handlerName started response streaming") + log.info("$handlerName started response streaming") client.streamResponse(context, response) } else { handler as LambdaBufferedHandler val response = bufferedResponse(context) { handler.handleRequest(event, context) } - Log.info("$handlerName invocation completed") - Log.debug(response) + log.info("$handlerName invocation completed") + log.debug(response) client.sendResponse(context, response, outputTypeInfo) } } catch (e: LambdaRuntimeException) { - Log.error(e) + log.error(e) + client.reportError(e) } catch (e: LambdaEnvironmentException) { when (e) { is NonRecoverableStateException -> { - Log.fatal(e) + log.fatal(e) - exitProcess(1) + env.terminate() } - else -> Log.error(e) + else -> log.error(e) } } catch (e: Throwable) { - Log.fatal(e) + log.fatal(e) + + env.terminate() + } - exitProcess(1) + if (singleEventMode) { + shouldExit = singleEventMode } } } -} -@PublishedApi -internal inline fun streamingResponse(crossinline handler: suspend (ByteWriteChannel) -> Unit) = - object : WriteChannelContent() { + inline fun streamingResponse(crossinline handler: suspend (ByteWriteChannel) -> Unit) = object : WriteChannelContent() { override suspend fun writeTo(channel: ByteWriteChannel) { try { handler(channel) } catch (e: Exception) { - Log.warn("Exception occurred on streaming: " + e.message) + log.warn("Exception occurred on streaming: " + e.message) channel.writeStringUtf8(e.toTrailer()) } @@ -128,9 +150,9 @@ internal inline fun streamingResponse(crossinline handler: suspend (ByteWriteCha "Lambda-Runtime-Function-Error-Type: Runtime.StreamError\r\nLambda-Runtime-Function-Error-Body: ${stackTraceToString().encodeBase64()}\r\n" } -@PublishedApi -internal inline fun T.bufferedResponse(context: Context, block: T.() -> R): R = try { - block() -} catch (e: Exception) { - throw e.asHandlerError(context) + inline fun T.bufferedResponse(context: Context, block: T.() -> R): R = try { + block() + } catch (e: Exception) { + throw e.asHandlerError(context) + } } \ No newline at end of file diff --git a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/api/LambdaRuntimeClient.kt b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/api/LambdaRuntimeClient.kt index d994b8f..8a8441f 100644 --- a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/api/LambdaRuntimeClient.kt +++ b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/api/LambdaRuntimeClient.kt @@ -27,11 +27,22 @@ import kotlin.time.Duration.Companion.minutes import io.ktor.http.ContentType.Application.Json as ContentTypeJson @PublishedApi -internal class LambdaClient(private val httpClient: HttpClient) { - private val invokeUrl = "http://${LambdaEnvironment.RUNTIME_API}/2018-06-01/runtime" +internal interface LambdaClient { + suspend fun retrieveNextEvent(bodyType: TypeInfo): Pair + suspend fun sendResponse(event: Context, body: T, bodyType: TypeInfo): HttpResponse + suspend fun streamResponse(event: Context, outgoingContent: OutgoingContent): HttpResponse + suspend fun reportError(error: LambdaRuntimeException) +} + +@PublishedApi +internal class LambdaClientImpl(private val httpClient: HttpClient): LambdaClient { + private val baseUrl = requireNotNull(LambdaEnvironment.RUNTIME_API) { + "Can't find AWS_LAMBDA_RUNTIME_API env variable" + } + private val invokeUrl = "http://$baseUrl/2018-06-01/runtime" private val requestTimeout = 15.minutes.inWholeMilliseconds - suspend fun retrieveNextEvent(bodyType: TypeInfo): Pair { + override suspend fun retrieveNextEvent(bodyType: TypeInfo): Pair { val response = httpClient.get { url("${invokeUrl}/invocation/next") @@ -47,7 +58,7 @@ internal class LambdaClient(private val httpClient: HttpClient) { return body to context } - suspend fun sendResponse(event: Context, body: T, bodyType: TypeInfo): HttpResponse { + override suspend fun sendResponse(event: Context, body: T, bodyType: TypeInfo): HttpResponse { val response = httpClient.post { url("${invokeUrl}/invocation/${event.awsRequestId}/response") contentType(ContentTypeJson) @@ -64,7 +75,7 @@ internal class LambdaClient(private val httpClient: HttpClient) { return validateResponse(response) } - suspend fun streamResponse(event: Context, outgoingContent: OutgoingContent): HttpResponse { + override suspend fun streamResponse(event: Context, outgoingContent: OutgoingContent): HttpResponse { val response = httpClient.post { url("${invokeUrl}/invocation/${event.awsRequestId}/response") @@ -89,13 +100,15 @@ internal class LambdaClient(private val httpClient: HttpClient) { return response } - suspend fun reportError(error: LambdaRuntimeException) { - val response = when (error) { + override suspend fun reportError(error: LambdaRuntimeException) { + when (error) { is LambdaRuntimeException.Init -> sendInitError(error) - is LambdaRuntimeException.Invocation -> sendInvocationError(error) - } + is LambdaRuntimeException.Invocation -> { + val response = sendInvocationError(error) - validateResponse(response) + validateResponse(response) + } + } } private suspend fun sendInvocationError( diff --git a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/api/dto/LogMessageDto.kt b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/api/dto/LogMessageDto.kt index ec2dcee..360a1ae 100644 --- a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/api/dto/LogMessageDto.kt +++ b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/api/dto/LogMessageDto.kt @@ -2,11 +2,8 @@ package io.github.trueangle.knative.lambda.runtime.api.dto import io.github.trueangle.knative.lambda.runtime.log.LogLevel import kotlinx.serialization.Contextual -import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder @Serializable internal data class LogMessageDto( diff --git a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/JsonLogFormatter.kt b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/JsonLogFormatter.kt index 0b395fd..744e2cc 100644 --- a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/JsonLogFormatter.kt +++ b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/JsonLogFormatter.kt @@ -2,6 +2,7 @@ package io.github.trueangle.knative.lambda.runtime.log import io.github.trueangle.knative.lambda.runtime.api.Context import io.github.trueangle.knative.lambda.runtime.api.dto.LogMessageDto +import io.github.trueangle.knative.lambda.runtime.prettyPrint import io.ktor.util.reflect.TypeInfo import kotlinx.datetime.Clock import kotlinx.serialization.SerializationException diff --git a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/Log.kt b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/Log.kt index 0372e38..9900d79 100644 --- a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/Log.kt +++ b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/Log.kt @@ -3,7 +3,6 @@ package io.github.trueangle.knative.lambda.runtime.log import io.github.trueangle.knative.lambda.runtime.LambdaEnvironment import io.github.trueangle.knative.lambda.runtime.LambdaRuntime import io.github.trueangle.knative.lambda.runtime.api.Context -import io.github.trueangle.knative.lambda.runtime.log.Log.write import io.ktor.util.reflect.TypeInfo import io.ktor.util.reflect.typeInfo @@ -16,49 +15,52 @@ internal interface LogFormatter { fun onContextAvailable(context: Context) = Unit } -object Log { - @PublishedApi +interface LambdaLogger { + fun log(level: LogLevel, message: T?, typeInfo: TypeInfo) + + fun setContext(context: Context) +} + +object Log : LambdaLogger { internal val currentLogLevel = LogLevel.fromEnv() private val writer = StdoutLogWriter() - private val logFormatter = if (LambdaEnvironment.LAMBDA_LOG_FORMAT == "JSON") { + private val logFormatter = if (LambdaEnvironment.LAMBDA_LOG_FORMAT.equals("JSON", ignoreCase = true)) { JsonLogFormatter(LambdaRuntime.json) } else { TextLogFormatter() } - inline fun trace(message: T?) { - write(LogLevel.TRACE, message, typeInfo()) + override fun setContext(context: Context) { + logFormatter.onContextAvailable(context) } - inline fun debug(message: T?) { - write(LogLevel.DEBUG, message, typeInfo()) + override fun log(level: LogLevel, message: T?, typeInfo: TypeInfo) { + if (level >= currentLogLevel) { + writer.write(level, logFormatter.format(level, message, typeInfo)) + } } +} - inline fun info(message: T?) { - write(LogLevel.INFO, message, typeInfo()) - } +inline fun LambdaLogger.trace(message: T?) { + log(LogLevel.TRACE, message, typeInfo()) +} - inline fun warn(message: T?) { - write(LogLevel.WARN, message, typeInfo()) - } +inline fun LambdaLogger.debug(message: T?) { + log(LogLevel.DEBUG, message, typeInfo()) +} - inline fun error(message: T?) { - write(LogLevel.ERROR, message, typeInfo()) - } +inline fun LambdaLogger.info(message: T?) { + log(LogLevel.INFO, message, typeInfo()) +} - inline fun fatal(message: T?) { - write(LogLevel.FATAL, message, typeInfo()) - } +inline fun LambdaLogger.warn(message: T?) { + log(LogLevel.WARN, message, typeInfo()) +} - @PublishedApi - internal fun setContext(context: Context) { - logFormatter.onContextAvailable(context) - } +inline fun LambdaLogger.error(message: T?) { + log(LogLevel.ERROR, message, typeInfo()) +} - @PublishedApi - internal fun write(level: LogLevel, message: Any?, typeInfo: TypeInfo) { - if (level >= currentLogLevel) { - writer.write(level, logFormatter.format(level, message, typeInfo)) - } - } +inline fun LambdaLogger.fatal(message: T?) { + log(LogLevel.FATAL, message, typeInfo()) } \ No newline at end of file diff --git a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/LogLevel.kt b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/LogLevel.kt index 53850bc..c16d91b 100644 --- a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/LogLevel.kt +++ b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/LogLevel.kt @@ -29,7 +29,7 @@ enum class LogLevel { companion object { fun fromEnv(): LogLevel { - val level = LambdaEnvironment.LAMBDA_LOG_LEVEL ?: "INFO" + val level = LambdaEnvironment.LAMBDA_LOG_LEVEL return runCatching { valueOf(level) }.getOrElse { diff --git a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/TextLogFormatter.kt b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/TextLogFormatter.kt index be23968..7232df1 100644 --- a/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/TextLogFormatter.kt +++ b/lambda-runtime/src/commonMain/kotlin/io/github/trueangle/knative/lambda/runtime/log/TextLogFormatter.kt @@ -1,5 +1,6 @@ package io.github.trueangle.knative.lambda.runtime.log +import io.github.trueangle.knative.lambda.runtime.prettyPrint import io.ktor.util.reflect.TypeInfo internal class TextLogFormatter : LogFormatter { diff --git a/lambda-runtime/src/nativeTest/kotlin/io/github/trueangle/knative/lambda/runtime/JsonLogFormatterTest.kt b/lambda-runtime/src/nativeTest/kotlin/io/github/trueangle/knative/lambda/runtime/JsonLogFormatterTest.kt index f7850b6..e5c1728 100644 --- a/lambda-runtime/src/nativeTest/kotlin/io/github/trueangle/knative/lambda/runtime/JsonLogFormatterTest.kt +++ b/lambda-runtime/src/nativeTest/kotlin/io/github/trueangle/knative/lambda/runtime/JsonLogFormatterTest.kt @@ -101,7 +101,5 @@ class JsonLogFormatterTest { assertEquals(expected, actual) } - @Serializable - private data class SampleObject(val hello: String) private data class NonSerialObject(val hello: String) } \ No newline at end of file diff --git a/lambda-runtime/src/nativeTest/kotlin/io/github/trueangle/knative/lambda/runtime/LambdaRuntimeTest.kt b/lambda-runtime/src/nativeTest/kotlin/io/github/trueangle/knative/lambda/runtime/LambdaRuntimeTest.kt new file mode 100644 index 0000000..3174fd9 --- /dev/null +++ b/lambda-runtime/src/nativeTest/kotlin/io/github/trueangle/knative/lambda/runtime/LambdaRuntimeTest.kt @@ -0,0 +1,230 @@ +package io.github.trueangle.knative.lambda.runtime + +import com.goncalossilva.resources.Resource +import dev.mokkery.answering.throws +import dev.mokkery.every +import dev.mokkery.matcher.any +import dev.mokkery.matcher.eq +import dev.mokkery.mock +import dev.mokkery.spy +import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode.Companion.exactly +import dev.mokkery.verify.VerifyMode.Companion.not +import dev.mokkery.verifySuspend +import io.github.trueangle.knative.lambda.runtime.ReservedRuntimeEnvironmentVariables.AWS_LAMBDA_FUNCTION_NAME +import io.github.trueangle.knative.lambda.runtime.ReservedRuntimeEnvironmentVariables.AWS_LAMBDA_FUNCTION_VERSION +import io.github.trueangle.knative.lambda.runtime.ReservedRuntimeEnvironmentVariables.AWS_LAMBDA_RUNTIME_API +import io.github.trueangle.knative.lambda.runtime.api.Context +import io.github.trueangle.knative.lambda.runtime.api.LambdaClient +import io.github.trueangle.knative.lambda.runtime.api.LambdaClientImpl +import io.github.trueangle.knative.lambda.runtime.events.apigateway.APIGatewayRequest +import io.github.trueangle.knative.lambda.runtime.handler.LambdaBufferedHandler +import io.github.trueangle.knative.lambda.runtime.log.LambdaLogger +import io.github.trueangle.knative.lambda.runtime.log.LogLevel.ERROR +import io.github.trueangle.knative.lambda.runtime.log.LogLevel.FATAL +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondBadRequest +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headers +import io.ktor.http.headersOf +import io.ktor.util.reflect.typeInfo +import io.ktor.utils.io.ByteReadChannel +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.toKString +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import platform.posix.getenv +import platform.posix.setenv +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFailsWith + +const val RESOURCES_PATH = "src/nativeTest/resources" + +class LambdaRuntimeTest { + private val log = mock() + private val context = Context( + awsRequestId = "156cb537-e2d4-11e8-9b34-d36013741fb9", + deadlineTimeInMs = 1542409706888L, + invokedFunctionArn = "arn", + clientContext = null, + cognitoIdentity = null, + invokedFunctionName = "invokedFunctionName", + invokedFunctionVersion = "1", + memoryLimitMb = 128, + xrayTracingId = null + ) + + @BeforeTest + fun setup() { + mockEnvironment() + } + + @Test + fun `GIVEN String event WHEN end-to-end THEN behave correctly`() = runTest { + val lambdaEvent = "Hello world" + val handlerResponse = "Response" + + val lambdaRunner = createRunner(MockEngine { request -> + val path = request.url.encodedPath + when { + path.contains("invocation/next") -> respond( + content = ByteReadChannel(lambdaEvent), + status = HttpStatusCode.OK, + headers = headers { + append(HttpHeaders.ContentType, "application/json") + append("Lambda-Runtime-Aws-Request-Id", context.awsRequestId) + append("Lambda-Runtime-Deadline-Ms", context.deadlineTimeInMs.toString()) + append("Lambda-Runtime-Invoked-Function-Arn", context.invokedFunctionArn) + + } + ) + + path.contains("/invocation/${context.awsRequestId}/response") -> respond( + content = ByteReadChannel("Ok"), + status = HttpStatusCode.Accepted, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + + else -> respondBadRequest() + } + }) + val client = lambdaRunner.client + + val handler = object : LambdaBufferedHandler { + override suspend fun handleRequest(input: String, context: Context): String = handlerResponse + } + + lambdaRunner.run(singleEventMode = true) { handler } + + verifySuspend { handler.handleRequest(lambdaEvent, context) } + verifySuspend(exactly(1)) { client.retrieveNextEvent(typeInfo()) } + verifySuspend(exactly(1)) { client.sendResponse(context, handlerResponse, typeInfo()) } + verify(not) { log.log(ERROR, any(), any()) } + verify(not) { log.log(FATAL, any(), any()) } + } + + @Test + fun `GIVEN complex object event and response WHEN end-to-end THEN behave correctly`() = runTest { + val lambdaEventJson = Resource("$RESOURCES_PATH/example-apigw-request.json").readText() + val apiGatewayRequest = Json.decodeFromString(lambdaEventJson) + val handlerResponse = SampleObject("Hello world") + + val lambdaRunner = createRunner(MockEngine { request -> + val path = request.url.encodedPath + when { + path.contains("invocation/next") -> respond( + content = ByteReadChannel(lambdaEventJson), + status = HttpStatusCode.OK, + headers = headers { + append(HttpHeaders.ContentType, "application/json") + append("Lambda-Runtime-Aws-Request-Id", context.awsRequestId) + append("Lambda-Runtime-Deadline-Ms", context.deadlineTimeInMs.toString()) + append("Lambda-Runtime-Invoked-Function-Arn", context.invokedFunctionArn) + } + ) + + path.contains("/invocation/${context.awsRequestId}/response") -> respond( + content = ByteReadChannel("Ok"), + status = HttpStatusCode.Accepted, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + + else -> respondBadRequest() + } + }) + val client = lambdaRunner.client + val handler = object : LambdaBufferedHandler { + override suspend fun handleRequest(input: APIGatewayRequest, context: Context) = handlerResponse + } + + lambdaRunner.run(singleEventMode = true) { handler } + + verifySuspend { handler.handleRequest(apiGatewayRequest, context) } + verifySuspend(exactly(1)) { client.retrieveNextEvent(typeInfo()) } + verifySuspend(exactly(1)) { client.sendResponse(context, handlerResponse, typeInfo()) } + verify(not) { log.log(ERROR, any(), any()) } + verify(not) { log.log(FATAL, any(), any()) } + } + + @Test + fun `GIVEN handler init error WHEN end-to-end THEN terminate immediately AND report init error`() = runTest { + val lambdaRunner = createRunner(MockEngine { request -> + val path = request.url.encodedPath + when { + path.contains("init/error") -> respond( + content = ByteReadChannel(""), + status = HttpStatusCode.Accepted, + ) + + else -> respondBadRequest() + } + }) + val client = lambdaRunner.client + + assertFailsWith { + lambdaRunner.run(singleEventMode = true) { InitErrorHandler() } + } + verifySuspend { client.reportError(any()) } + verifySuspend(not) { client.retrieveNextEvent(typeInfo()) } + verify(not) { log.log(ERROR, any(), any()) } + verify { log.log(FATAL, any(), any()) } + } + + @Test + fun `GIVEN handler init error AND report init failed WHEN end-to-end THEN terminate immediately`() = runTest { + val lambdaRunner = createRunner(MockEngine { request -> + val path = request.url.encodedPath + when { + path.contains("init/error") -> respondBadRequest() + else -> respondBadRequest() + } + }) + val client = lambdaRunner.client + + assertFailsWith { + lambdaRunner.run(singleEventMode = true) { InitErrorHandler() } + } + verifySuspend { client.reportError(any()) } + verifySuspend(not) { client.retrieveNextEvent(typeInfo()) } + verify(not) { log.log(ERROR, any(), any()) } + verify { log.log(FATAL, any(), any()) } + } + + @OptIn(ExperimentalForeignApi::class) + private fun mockEnvironment() { + if (getenv(AWS_LAMBDA_FUNCTION_NAME)?.toKString().isNullOrEmpty()) { + setenv(AWS_LAMBDA_FUNCTION_NAME, context.invokedFunctionName, 1) + } + + if (getenv(AWS_LAMBDA_FUNCTION_VERSION)?.toKString().isNullOrEmpty()) { + setenv(AWS_LAMBDA_FUNCTION_VERSION, context.invokedFunctionVersion, 1) + } + + if (getenv(AWS_LAMBDA_RUNTIME_API)?.toKString().isNullOrEmpty()) { + setenv(AWS_LAMBDA_RUNTIME_API, "127.0.0.1", 1) + } + } + + private fun createRunner(mockEngine: HttpClientEngine): Runner { + val client = LambdaClientImpl(LambdaRuntime.createHttpClient(mockEngine)) + val env = mock { + every { terminate() } throws TerminateException() + } + val lambdaClient = spy(client) + return Runner(lambdaClient, log, env) + } + + private class InitErrorHandler : LambdaBufferedHandler { + init { + throw RuntimeException() + } + + override suspend fun handleRequest(input: String, context: Context) = "" + } + + private class TerminateException : Error() +} \ No newline at end of file diff --git a/lambda-runtime/src/nativeTest/kotlin/io/github/trueangle/knative/lambda/runtime/Mocks.kt b/lambda-runtime/src/nativeTest/kotlin/io/github/trueangle/knative/lambda/runtime/Mocks.kt index a3a9ba9..74dc89f 100644 --- a/lambda-runtime/src/nativeTest/kotlin/io/github/trueangle/knative/lambda/runtime/Mocks.kt +++ b/lambda-runtime/src/nativeTest/kotlin/io/github/trueangle/knative/lambda/runtime/Mocks.kt @@ -1,8 +1,9 @@ package io.github.trueangle.knative.lambda.runtime import io.github.trueangle.knative.lambda.runtime.api.Context +import kotlinx.serialization.Serializable -internal fun mockContext(awsRequestId: String) = Context( +internal fun mockContext(awsRequestId: String = "awsRequestId") = Context( awsRequestId = awsRequestId, xrayTracingId = "dummyXrayTracingId", deadlineTimeInMs = 100, @@ -12,4 +13,7 @@ internal fun mockContext(awsRequestId: String) = Context( memoryLimitMb = 512, clientContext = null, cognitoIdentity = null -) \ No newline at end of file +) + +@Serializable +internal data class SampleObject(val hello: String) \ No newline at end of file diff --git a/lambda-runtime/src/nativeTest/resources/example-apigw-request.json b/lambda-runtime/src/nativeTest/resources/example-apigw-request.json new file mode 100644 index 0000000..d91e960 --- /dev/null +++ b/lambda-runtime/src/nativeTest/resources/example-apigw-request.json @@ -0,0 +1,135 @@ +{ + "resource": "/{proxy+}", + "path": "/hello/world", + "httpMethod": "POST", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "cache-control": "no-cache", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-Country": "US", + "Content-Type": "application/json", + "headerName": "headerValue", + "Host": "gy415nuibc.execute-api.us-east-1.amazonaws.com", + "Postman-Token": "9f583ef0-ed83-4a38-aef3-eb9ce3f7a57f", + "User-Agent": "PostmanRuntime/2.4.5", + "Via": "1.1 d98420743a69852491bbdea73f7680bd.cloudfront.net (CloudFront)", + "X-Amz-Cf-Id": "pn-PWIJc6thYnZm5P0NMgOUglL1DYtl0gdeJky8tqsg8iS_sgsKD1A==", + "X-Forwarded-For": "54.240.196.186, 54.182.214.83", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "cache-control": [ + "no-cache" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-Country": [ + "US" + ], + "Content-Type": [ + "application/json" + ], + "headerName": [ + "headerValue" + ], + "Host": [ + "gy415nuibc.execute-api.us-east-1.amazonaws.com" + ], + "Postman-Token": [ + "9f583ef0-ed83-4a38-aef3-eb9ce3f7a57f" + ], + "User-Agent": [ + "PostmanRuntime/2.4.5" + ], + "Via": [ + "1.1 d98420743a69852491bbdea73f7680bd.cloudfront.net (CloudFront)" + ], + "X-Amz-Cf-Id": [ + "pn-PWIJc6thYnZm5P0NMgOUglL1DYtl0gdeJky8tqsg8iS_sgsKD1A==" + ], + "X-Forwarded-For": [ + "54.240.196.186, 54.182.214.83" + ], + "X-Forwarded-Port": [ + "443" + ], + "X-Forwarded-Proto": [ + "https" + ] + }, + "queryStringParameters": { + "name": "me" + }, + "multiValueQueryStringParameters": { + "name": [ + "me" + ] + }, + "pathParameters": { + "proxy": "hello/world" + }, + "stageVariables": { + "stageVariableName": "stageVariableValue" + }, + "requestContext": { + "accountId": "12345678912", + "resourceId": "roq9wj", + "path": "/hello/world", + "stage": "testStage", + "domainName": "gy415nuibc.execute-api.us-east-2.amazonaws.com", + "domainPrefix": "y0ne18dixk", + "requestId": "deef4878-7910-11e6-8f14-25afc3e9ae33", + "protocol": "HTTP/1.1", + "identity": { + "cognitoIdentityPoolId": "theCognitoIdentityPoolId", + "accountId": "theAccountId", + "cognitoIdentityId": "theCognitoIdentityId", + "caller": "theCaller", + "apiKey": "theApiKey", + "apiKeyId": "theApiKeyId", + "accessKey": "ANEXAMPLEOFACCESSKEY", + "sourceIp": "192.168.196.186", + "cognitoAuthenticationType": "theCognitoAuthenticationType", + "cognitoAuthenticationProvider": "theCognitoAuthenticationProvider", + "userArn": "theUserArn", + "userAgent": "PostmanRuntime/2.4.5", + "user": "theUser" + }, + "authorizer": { + "principalId": "admin", + "clientId": 1, + "clientName": "Exata" + }, + "resourcePath": "/{proxy+}", + "httpMethod": "POST", + "requestTime": "15/May/2020:06:01:09 +0000", + "requestTimeEpoch": 1589522469693, + "apiId": "gy415nuibc" + }, + "body": "{\r\n\t\"a\": 1\r\n}" +} \ No newline at end of file diff --git a/sample/src/nativeMain/kotlin/com/github/trueangle/knative/lambda/runtime/sample/handler/ByteBodyLambdaHandler.kt b/sample/src/nativeMain/kotlin/com/github/trueangle/knative/lambda/runtime/sample/handler/ByteBodyLambdaHandler.kt index 561f9e4..cf7e3d7 100644 --- a/sample/src/nativeMain/kotlin/com/github/trueangle/knative/lambda/runtime/sample/handler/ByteBodyLambdaHandler.kt +++ b/sample/src/nativeMain/kotlin/com/github/trueangle/knative/lambda/runtime/sample/handler/ByteBodyLambdaHandler.kt @@ -4,6 +4,7 @@ import io.github.trueangle.knative.lambda.runtime.api.Context import io.github.trueangle.knative.lambda.runtime.handler.LambdaBufferedHandler import io.github.trueangle.knative.lambda.runtime.handler.LambdaHandler import io.github.trueangle.knative.lambda.runtime.log.Log +import io.github.trueangle.knative.lambda.runtime.log.debug import io.ktor.utils.io.core.toByteArray class ByteBodyLambdaHandler : LambdaBufferedHandler { diff --git a/sample/src/nativeMain/kotlin/com/github/trueangle/knative/lambda/runtime/sample/handler/ObjectBodyLambdaHandler.kt b/sample/src/nativeMain/kotlin/com/github/trueangle/knative/lambda/runtime/sample/handler/ObjectBodyLambdaHandler.kt index 675f725..eb40894 100644 --- a/sample/src/nativeMain/kotlin/com/github/trueangle/knative/lambda/runtime/sample/handler/ObjectBodyLambdaHandler.kt +++ b/sample/src/nativeMain/kotlin/com/github/trueangle/knative/lambda/runtime/sample/handler/ObjectBodyLambdaHandler.kt @@ -1,12 +1,9 @@ package com.github.trueangle.knative.lambda.runtime.sample.handler import io.github.trueangle.knative.lambda.runtime.api.Context -import io.github.trueangle.knative.lambda.runtime.events.apigateway.APIGatewayProxy import io.github.trueangle.knative.lambda.runtime.events.apigateway.APIGatewayV2Request import io.github.trueangle.knative.lambda.runtime.events.apigateway.APIGatewayV2Response import io.github.trueangle.knative.lambda.runtime.handler.LambdaBufferedHandler -import io.github.trueangle.knative.lambda.runtime.handler.LambdaHandler -import io.github.trueangle.knative.lambda.runtime.log.Log import kotlinx.serialization.Serializable class ObjectBodyLambdaHandler : LambdaBufferedHandler {