diff --git a/.changes/756754c3-f6e1-4ff2-ae31-08b3b67b6750.json b/.changes/756754c3-f6e1-4ff2-ae31-08b3b67b6750.json new file mode 100644 index 000000000..84d27c4cc --- /dev/null +++ b/.changes/756754c3-f6e1-4ff2-ae31-08b3b67b6750.json @@ -0,0 +1,5 @@ +{ + "id": "756754c3-f6e1-4ff2-ae31-08b3b67b6750", + "type": "feature", + "description": "Add support for [smoke tests](https://smithy.io/2.0/additional-specs/smoke-tests.html)" +} \ No newline at end of file diff --git a/codegen/smithy-kotlin-codegen/build.gradle.kts b/codegen/smithy-kotlin-codegen/build.gradle.kts index 8c8a662f7..956418641 100644 --- a/codegen/smithy-kotlin-codegen/build.gradle.kts +++ b/codegen/smithy-kotlin-codegen/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(libs.smithy.aws.traits) implementation(libs.smithy.protocol.traits) implementation(libs.smithy.protocol.test.traits) + implementation(libs.smithy.smoke.test.traits) implementation(libs.jsoup) // Test dependencies diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinDelegator.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinDelegator.kt index 7b5b871e6..9ae10da76 100644 --- a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinDelegator.kt +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinDelegator.kt @@ -15,7 +15,7 @@ import software.amazon.smithy.model.shapes.Shape import java.nio.file.Paths const val DEFAULT_SOURCE_SET_ROOT = "./src/main/kotlin/" -private const val DEFAULT_TEST_SOURCE_SET_ROOT = "./src/test/kotlin/" +const val DEFAULT_TEST_SOURCE_SET_ROOT = "./src/test/kotlin/" /** * Manages writers for Kotlin files. @@ -121,9 +121,10 @@ class KotlinDelegator( * * @param filename Name of the file to create. * @param block Lambda that accepts and works with the file. + * @param sourceSetRoot Root directory for source set */ - fun useFileWriter(filename: String, namespace: String, block: (KotlinWriter) -> Unit) { - val writer: KotlinWriter = checkoutWriter(filename, namespace) + fun useFileWriter(filename: String, namespace: String, sourceSetRoot: String = DEFAULT_SOURCE_SET_ROOT, block: (KotlinWriter) -> Unit) { + val writer: KotlinWriter = checkoutWriter(filename, namespace, sourceSetRoot) block(writer) } @@ -205,6 +206,6 @@ internal data class GeneratedDependency( } fun KotlinDelegator.useFileWriter(symbol: Symbol, block: (KotlinWriter) -> Unit) = - useFileWriter("${symbol.name}.kt", symbol.namespace, block) + useFileWriter("${symbol.name}.kt", symbol.namespace, DEFAULT_SOURCE_SET_ROOT, block) fun KotlinDelegator.applyFileWriter(symbol: Symbol, block: KotlinWriter.() -> Unit) = useFileWriter(symbol, block) diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinDependency.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinDependency.kt index ec4c117aa..524e1924a 100644 --- a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinDependency.kt +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/KotlinDependency.kt @@ -104,6 +104,7 @@ data class KotlinDependency( val CORE = KotlinDependency(GradleConfiguration.Api, RUNTIME_ROOT_NS, RUNTIME_GROUP, "runtime-core", RUNTIME_VERSION) val HTTP = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.http", RUNTIME_GROUP, "http", RUNTIME_VERSION) val HTTP_CLIENT = KotlinDependency(GradleConfiguration.Api, "$RUNTIME_ROOT_NS.http", RUNTIME_GROUP, "http-client", RUNTIME_VERSION) + val HTTP_TEST = KotlinDependency(GradleConfiguration.Api, "$RUNTIME_ROOT_NS.httptest", RUNTIME_GROUP, "http-test", RUNTIME_VERSION) val SERDE = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.serde", RUNTIME_GROUP, "serde", RUNTIME_VERSION) val SERDE_JSON = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.serde.json", RUNTIME_GROUP, "serde-json", RUNTIME_VERSION) val SERDE_XML = KotlinDependency(GradleConfiguration.Implementation, "$RUNTIME_ROOT_NS.serde.xml", RUNTIME_GROUP, "serde-xml", RUNTIME_VERSION) diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt index d25e1c01c..d37e47737 100644 --- a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt @@ -86,9 +86,16 @@ object RuntimeTypes { val FlexibleChecksumsResponseInterceptor = symbol("FlexibleChecksumsResponseInterceptor") val ResponseLengthValidationInterceptor = symbol("ResponseLengthValidationInterceptor") val RequestCompressionInterceptor = symbol("RequestCompressionInterceptor") + val SmokeTestsInterceptor = symbol("SmokeTestsInterceptor") + val SmokeTestsFailureException = symbol("SmokeTestsFailureException") + val SmokeTestsSuccessException = symbol("SmokeTestsSuccessException") } } + object HttpTest : RuntimeTypePackage(KotlinDependency.HTTP_TEST) { + val TestEngine = symbol("TestEngine") + } + object Core : RuntimeTypePackage(KotlinDependency.CORE) { val Clock = symbol("Clock", "time") val ExecutionContext = symbol("ExecutionContext", "operation") @@ -107,6 +114,10 @@ object RuntimeTypes { val SmithyBusinessMetric = symbol("SmithyBusinessMetric") } + object SmokeTests : RuntimeTypePackage(KotlinDependency.CORE, "smoketests") { + val exitProcess = symbol("exitProcess") + } + object Collections : RuntimeTypePackage(KotlinDependency.CORE, "collections") { val Attributes = symbol("Attributes") val attributesOf = symbol("attributesOf") @@ -163,6 +174,7 @@ object RuntimeTypes { val Closeable = symbol("Closeable") val SdkManagedGroup = symbol("SdkManagedGroup") val addIfManaged = symbol("addIfManaged", isExtension = true) + val use = symbol("use") } object Text : RuntimeTypePackage(KotlinDependency.CORE, "text") { @@ -184,6 +196,7 @@ object RuntimeTypes { val truthiness = symbol("truthiness") val toNumber = symbol("toNumber") val type = symbol("type") + val PlatformProvider = symbol("PlatformProvider") } object Net : RuntimeTypePackage(KotlinDependency.CORE, "net") { diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsIntegration.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsIntegration.kt new file mode 100644 index 000000000..192494e9e --- /dev/null +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsIntegration.kt @@ -0,0 +1,31 @@ +package software.amazon.smithy.kotlin.codegen.rendering.smoketests + +import software.amazon.smithy.kotlin.codegen.KotlinSettings +import software.amazon.smithy.kotlin.codegen.core.CodegenContext +import software.amazon.smithy.kotlin.codegen.core.DEFAULT_TEST_SOURCE_SET_ROOT +import software.amazon.smithy.kotlin.codegen.core.KotlinDelegator +import software.amazon.smithy.kotlin.codegen.integration.KotlinIntegration +import software.amazon.smithy.kotlin.codegen.model.hasTrait +import software.amazon.smithy.kotlin.codegen.utils.topDownOperations +import software.amazon.smithy.model.Model +import software.amazon.smithy.smoketests.traits.SmokeTestsTrait + +/** + * Renders smoke test runner for a service if any of the operations have the [SmokeTestsTrait]. + */ +class SmokeTestsIntegration : KotlinIntegration { + override fun enabledForService(model: Model, settings: KotlinSettings): Boolean = + model.topDownOperations(settings.service).any { it.hasTrait() } + + override fun writeAdditionalFiles(ctx: CodegenContext, delegator: KotlinDelegator) = + delegator.useFileWriter( + "SmokeTests.kt", + "${ctx.settings.pkg.name}.smoketests", + DEFAULT_TEST_SOURCE_SET_ROOT, + ) { writer -> + SmokeTestsRunnerGenerator( + writer, + ctx, + ).render() + } +} diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsRunnerGenerator.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsRunnerGenerator.kt new file mode 100644 index 000000000..edcf8b7b2 --- /dev/null +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsRunnerGenerator.kt @@ -0,0 +1,193 @@ +package software.amazon.smithy.kotlin.codegen.rendering.smoketests + +import software.amazon.smithy.codegen.core.Symbol +import software.amazon.smithy.kotlin.codegen.core.* +import software.amazon.smithy.kotlin.codegen.integration.SectionId +import software.amazon.smithy.kotlin.codegen.model.getTrait +import software.amazon.smithy.kotlin.codegen.model.hasTrait +import software.amazon.smithy.kotlin.codegen.rendering.util.format +import software.amazon.smithy.kotlin.codegen.utils.dq +import software.amazon.smithy.kotlin.codegen.utils.toCamelCase +import software.amazon.smithy.kotlin.codegen.utils.topDownOperations +import software.amazon.smithy.model.shapes.OperationShape +import software.amazon.smithy.smoketests.traits.SmokeTestCase +import software.amazon.smithy.smoketests.traits.SmokeTestsTrait +import kotlin.jvm.optionals.getOrNull + +object SmokeTestsRunner : SectionId +object SmokeTestAdditionalEnvVars : SectionId +object SmokeTestDefaultConfig : SectionId +object SmokeTestRegionDefault : SectionId +object SmokeTestHttpEngineOverride : SectionId + +const val SKIP_TAGS = "AWS_SMOKE_TEST_SKIP_TAGS" +const val SERVICE_FILTER = "AWS_SMOKE_TEST_SERVICE_IDS" + +/** + * Renders smoke tests runner for a service + */ +class SmokeTestsRunnerGenerator( + private val writer: KotlinWriter, + ctx: CodegenContext, +) { + private val model = ctx.model + private val sdkId = ctx.settings.sdkId + private val symbolProvider = ctx.symbolProvider + private val service = symbolProvider.toSymbol(model.expectShape(ctx.settings.service)) + private val operations = ctx.model.topDownOperations(ctx.settings.service).filter { it.hasTrait() } + + internal fun render() { + writer.declareSection(SmokeTestsRunner) { + write("private var exitCode = 0") + write( + "private val skipTags = #T.System.getenv(#S)?.let { it.split(#S).map { it.trim() }.toSet() } ?: emptySet()", + RuntimeTypes.Core.Utils.PlatformProvider, + SKIP_TAGS, + ",", + ) + write( + "private val serviceFilter = #T.System.getenv(#S)?.let { it.split(#S).map { it.trim() }.toSet() } ?: emptySet()", + RuntimeTypes.Core.Utils.PlatformProvider, + SERVICE_FILTER, + ",", + ) + declareSection(SmokeTestAdditionalEnvVars) + write("") + withBlock("public suspend fun main() {", "}") { + renderFunctionCalls() + write("#T(exitCode)", RuntimeTypes.Core.SmokeTests.exitProcess) + } + write("") + renderFunctions() + } + } + + private fun renderFunctionCalls() { + operations.forEach { operation -> + operation.getTrait()?.testCases?.forEach { testCase -> + writer.write("${testCase.functionName}()") + } + } + } + + private fun renderFunctions() { + operations.forEach { operation -> + operation.getTrait()?.testCases?.forEach { testCase -> + renderFunction(operation, testCase) + writer.write("") + } + } + } + + private fun renderFunction(operation: OperationShape, testCase: SmokeTestCase) { + writer.withBlock("private suspend fun ${testCase.functionName}() {", "}") { + write("val tags = setOf(${testCase.tags.joinToString(",") { it.dq()} })") + writer.withBlock("if ((serviceFilter.isNotEmpty() && #S !in serviceFilter) || tags.any { it in skipTags }) {", "}", sdkId) { + printTestResult( + sdkId.filter { !it.isWhitespace() }, + testCase.id, + testCase.expectation.isFailure, + writer, + "ok", + "# skip", + ) + writer.write("return") + } + write("") + withInlineBlock("try {", "} ") { + renderClient(testCase) + renderOperation(operation, testCase) + } + withBlock("catch (e: Exception) {", "}") { + renderCatchBlock(testCase) + } + } + } + + private fun renderClient(testCase: SmokeTestCase) { + writer.withInlineBlock("#L {", "}", service) { + if (testCase.vendorParams.isPresent) { + testCase.vendorParams.get().members.forEach { vendorParam -> + if (vendorParam.key.value == "region") { + writeInline("#L = ", vendorParam.key.value.toCamelCase()) + declareSection(SmokeTestRegionDefault) + write("#L", vendorParam.value.format()) + } else { + write("#L = #L", vendorParam.key.value.toCamelCase(), vendorParam.value.format()) + } + } + } else { + declareSection(SmokeTestDefaultConfig) + } + val expectingSpecificError = testCase.expectation.failure.getOrNull()?.errorId?.getOrNull() != null + if (!expectingSpecificError) { + write("interceptors.add(#T())", RuntimeTypes.HttpClient.Interceptors.SmokeTestsInterceptor) + } + declareSection(SmokeTestHttpEngineOverride) + } + } + + private fun renderOperation(operation: OperationShape, testCase: SmokeTestCase) { + val operationSymbol = symbolProvider.toSymbol(model.getShape(operation.input.get()).get()) + + writer.withBlock(".#T { client ->", "}", RuntimeTypes.Core.IO.use) { + withBlock("client.#L(", ")", operation.defaultName()) { + withBlock("#L {", "}", operationSymbol) { + testCase.params.get().members.forEach { member -> + write("#L = #L", member.key.value.toCamelCase(), member.value.format()) + } + } + } + } + } + + private fun renderCatchBlock(testCase: SmokeTestCase) { + val expected = if (testCase.expectation.isFailure) { + getFailureCriterion(testCase) + } else { + RuntimeTypes.HttpClient.Interceptors.SmokeTestsSuccessException + } + + writer.write("val success = e is #T", expected) + writer.write("val status = if (success) #S else #S", "ok", "not ok") + printTestResult( + sdkId.filter { !it.isWhitespace() }, + testCase.id, + testCase.expectation.isFailure, + writer, + ) + writer.write("if (!success) exitCode = 1") + } + + /** + * Tries to get the specific exception required in the failure criterion of a test. + * If no specific exception is required we default to the generic smoke tests failure exception. + */ + private fun getFailureCriterion(testCase: SmokeTestCase): Symbol = + testCase.expectation.failure.getOrNull()?.errorId?.getOrNull()?.let { + symbolProvider.toSymbol(model.getShape(it).get()) + } ?: RuntimeTypes.HttpClient.Interceptors.SmokeTestsFailureException + + /** + * Renders print statement for smoke test result in accordance to design doc & test anything protocol (TAP) + */ + private fun printTestResult( + service: String, + testCase: String, + errorExpected: Boolean, + writer: KotlinWriter, + statusOverride: String? = null, + directive: String? = "", + ) { + val expectation = if (errorExpected) "error expected from service" else "no error expected from service" + val status = statusOverride ?: "\$status" + val testResult = "$status $service $testCase - $expectation $directive" + writer.write("println(#S)", testResult) + } +} + +/** + * Derives a function name for a [SmokeTestCase] + */ +private val SmokeTestCase.functionName: String + get() = this.id.toCamelCase() diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/util/Node.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/util/Node.kt new file mode 100644 index 000000000..1e7d3103c --- /dev/null +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/util/Node.kt @@ -0,0 +1,27 @@ +package software.amazon.smithy.kotlin.codegen.rendering.util + +import software.amazon.smithy.kotlin.codegen.utils.dq +import software.amazon.smithy.model.node.ArrayNode +import software.amazon.smithy.model.node.BooleanNode +import software.amazon.smithy.model.node.Node +import software.amazon.smithy.model.node.NullNode +import software.amazon.smithy.model.node.NumberNode +import software.amazon.smithy.model.node.ObjectNode +import software.amazon.smithy.model.node.StringNode + +/** + * Formats a [Node] into a String for codegen. + */ +fun Node.format(): String = when (this) { + is NullNode -> "null" + is StringNode -> value.dq() + is BooleanNode -> value.toString() + is NumberNode -> value.toString() + is ArrayNode -> elements.joinToString(",", "listOf(", ")") { element -> + element.format() + } + is ObjectNode -> stringMap.entries.joinToString(", ", "mapOf(", ")") { (key, value) -> + "${key.dq()} to ${value.format()}" + } + else -> throw Exception("Unexpected node type: $this") +} diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/utils/Model.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/utils/Model.kt new file mode 100644 index 000000000..27b1ced80 --- /dev/null +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/utils/Model.kt @@ -0,0 +1,14 @@ +package software.amazon.smithy.kotlin.codegen.utils + +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.knowledge.TopDownIndex +import software.amazon.smithy.model.shapes.OperationShape +import software.amazon.smithy.model.shapes.ShapeId + +/** + * Syntactic sugar for getting a services operations + */ +fun Model.topDownOperations(service: ShapeId): Set { + val topDownIndex = TopDownIndex.of(this) + return topDownIndex.getContainedOperations(service) +} diff --git a/codegen/smithy-kotlin-codegen/src/main/resources/META-INF/services/software.amazon.smithy.kotlin.codegen.integration.KotlinIntegration b/codegen/smithy-kotlin-codegen/src/main/resources/META-INF/services/software.amazon.smithy.kotlin.codegen.integration.KotlinIntegration index a230428ee..0d02b5118 100644 --- a/codegen/smithy-kotlin-codegen/src/main/resources/META-INF/services/software.amazon.smithy.kotlin.codegen.integration.KotlinIntegration +++ b/codegen/smithy-kotlin-codegen/src/main/resources/META-INF/services/software.amazon.smithy.kotlin.codegen.integration.KotlinIntegration @@ -12,3 +12,4 @@ software.amazon.smithy.kotlin.codegen.rendering.endpoints.discovery.EndpointDisc software.amazon.smithy.kotlin.codegen.rendering.endpoints.SdkEndpointBuiltinIntegration software.amazon.smithy.kotlin.codegen.rendering.compression.RequestCompressionIntegration software.amazon.smithy.kotlin.codegen.rendering.auth.SigV4AsymmetricAuthSchemeIntegration +software.amazon.smithy.kotlin.codegen.rendering.smoketests.SmokeTestsIntegration diff --git a/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsRunnerGeneratorTest.kt b/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsRunnerGeneratorTest.kt new file mode 100644 index 000000000..ddb5b3c1d --- /dev/null +++ b/codegen/smithy-kotlin-codegen/src/test/kotlin/software/amazon/smithy/kotlin/codegen/rendering/smoketests/SmokeTestsRunnerGeneratorTest.kt @@ -0,0 +1,209 @@ +package software.amazon.smithy.kotlin.codegen.rendering.smoketests + +import software.amazon.smithy.kotlin.codegen.test.* +import software.amazon.smithy.model.Model +import kotlin.test.Test + +class SmokeTestsRunnerGeneratorTest { + val model = + """ + ${'$'}version: "2" + namespace com.test + + use smithy.test#smokeTests + + service Test { + version: "1.0.0", + operations: [ TestOperation ], + } + + @smokeTests( + [ + { + id: "SuccessTest" + params: {bar: "2"} + tags: [ + "success" + ] + expect: { + success: {} + } + vendorParamsShape: AwsVendorParams, + vendorParams: { + region: "eu-central-1" + } + } + { + id: "InvalidMessageErrorTest" + params: {bar: "föö"} + expect: { + failure: {errorId: InvalidMessageError} + } + } + { + id: "FailureTest" + params: {bar: "föö"} + expect: { + failure: {} + } + } + ] + ) + + operation TestOperation { + input := { + bar: String + } + errors: [ + InvalidMessageError + ] + } + + @error("client") + structure InvalidMessageError {} + + structure AwsVendorParams { + region: String + } + """.toSmithyModel() + + private val generatedCode = generateSmokeTests(model) + + @Test + fun variablesTest() { + generatedCode.shouldContainOnlyOnceWithDiff( + """ + private var exitCode = 0 + private val skipTags = PlatformProvider.System.getenv("AWS_SMOKE_TEST_SKIP_TAGS")?.let { it.split(",").map { it.trim() }.toSet() } ?: emptySet() + private val serviceFilter = PlatformProvider.System.getenv("AWS_SMOKE_TEST_SERVICE_IDS")?.let { it.split(",").map { it.trim() }.toSet() } + """.trimIndent(), + ) + } + + @Test + fun mainTest() { + generatedCode.shouldContainOnlyOnceWithDiff( + """ + public suspend fun main() { + successTest() + invalidMessageErrorTest() + failureTest() + exitProcess(exitCode) + } + """.trimIndent(), + ) + } + + @Test + fun successTest() { + generatedCode.shouldContainOnlyOnceWithDiff( + """ + private suspend fun successTest() { + val tags = setOf("success") + if ((serviceFilter.isNotEmpty() && "Test" !in serviceFilter) || tags.any { it in skipTags }) { + println("ok Test SuccessTest - no error expected from service # skip") + return + } + + try { + com.test.TestClient { + region = "eu-central-1" + interceptors.add(SmokeTestsInterceptor()) + + }.use { client -> + client.testOperation( + com.test.model.TestOperationRequest { + bar = "2" + } + ) + } + + } catch (e: Exception) { + val success = e is SmokeTestsSuccessException + val status = if (success) "ok" else "not ok" + println("${'$'}status Test SuccessTest - no error expected from service ") + if (!success) exitCode = 1 + } + } + """.trimIndent(), + ) + } + + @Test + fun invalidMessageErrorTest() { + generatedCode.shouldContainOnlyOnceWithDiff( + """ + private suspend fun invalidMessageErrorTest() { + val tags = setOf() + if ((serviceFilter.isNotEmpty() && "Test" !in serviceFilter) || tags.any { it in skipTags }) { + println("ok Test InvalidMessageErrorTest - error expected from service # skip") + return + } + + try { + com.test.TestClient { + + }.use { client -> + client.testOperation( + com.test.model.TestOperationRequest { + bar = "föö" + } + ) + } + + } catch (e: Exception) { + val success = e is InvalidMessageError + val status = if (success) "ok" else "not ok" + println("${'$'}status Test InvalidMessageErrorTest - error expected from service ") + if (!success) exitCode = 1 + } + } + """.trimIndent(), + ) + } + + @Test + fun failureTest() { + generatedCode.shouldContainOnlyOnceWithDiff( + """ + private suspend fun failureTest() { + val tags = setOf() + if ((serviceFilter.isNotEmpty() && "Test" !in serviceFilter) || tags.any { it in skipTags }) { + println("ok Test FailureTest - error expected from service # skip") + return + } + + try { + com.test.TestClient { + interceptors.add(SmokeTestsInterceptor()) + + }.use { client -> + client.testOperation( + com.test.model.TestOperationRequest { + bar = "föö" + } + ) + } + + } catch (e: Exception) { + val success = e is SmokeTestsFailureException + val status = if (success) "ok" else "not ok" + println("${'$'}status Test FailureTest - error expected from service ") + if (!success) exitCode = 1 + } + } + """.trimIndent(), + ) + } + + private fun generateSmokeTests(model: Model): String { + val testCtx = model.newTestContext() + val codegenCtx = testCtx.toCodegenContext() + val writer = testCtx.newWriter() + SmokeTestsRunnerGenerator( + writer, + codegenCtx, + ).render() + return writer.toString() + } +} diff --git a/runtime/protocol/http-client/api/http-client.api b/runtime/protocol/http-client/api/http-client.api index 9927ae030..c15a33b0b 100644 --- a/runtime/protocol/http-client/api/http-client.api +++ b/runtime/protocol/http-client/api/http-client.api @@ -424,6 +424,37 @@ public final class aws/smithy/kotlin/runtime/http/interceptors/ResponseLengthVal public fun readBeforeTransmit (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;)V } +public final class aws/smithy/kotlin/runtime/http/interceptors/SmokeTestsFailureException : java/lang/Exception { + public fun ()V +} + +public final class aws/smithy/kotlin/runtime/http/interceptors/SmokeTestsInterceptor : aws/smithy/kotlin/runtime/client/Interceptor { + public fun ()V + public fun modifyBeforeAttemptCompletion-gIAlu-s (Laws/smithy/kotlin/runtime/client/ResponseInterceptorContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun modifyBeforeCompletion-gIAlu-s (Laws/smithy/kotlin/runtime/client/ResponseInterceptorContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun modifyBeforeDeserialization (Laws/smithy/kotlin/runtime/client/ProtocolResponseInterceptorContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun modifyBeforeRetryLoop (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun modifyBeforeSerialization (Laws/smithy/kotlin/runtime/client/RequestInterceptorContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun modifyBeforeSigning (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun modifyBeforeTransmit (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun readAfterAttempt (Laws/smithy/kotlin/runtime/client/ResponseInterceptorContext;)V + public fun readAfterDeserialization (Laws/smithy/kotlin/runtime/client/ResponseInterceptorContext;)V + public fun readAfterExecution (Laws/smithy/kotlin/runtime/client/ResponseInterceptorContext;)V + public fun readAfterSerialization (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;)V + public fun readAfterSigning (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;)V + public fun readAfterTransmit (Laws/smithy/kotlin/runtime/client/ProtocolResponseInterceptorContext;)V + public fun readBeforeAttempt (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;)V + public fun readBeforeDeserialization (Laws/smithy/kotlin/runtime/client/ProtocolResponseInterceptorContext;)V + public fun readBeforeExecution (Laws/smithy/kotlin/runtime/client/RequestInterceptorContext;)V + public fun readBeforeSerialization (Laws/smithy/kotlin/runtime/client/RequestInterceptorContext;)V + public fun readBeforeSigning (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;)V + public fun readBeforeTransmit (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;)V +} + +public final class aws/smithy/kotlin/runtime/http/interceptors/SmokeTestsSuccessException : java/lang/Exception { + public fun ()V +} + public final class aws/smithy/kotlin/runtime/http/middleware/DefaultValidateResponse : aws/smithy/kotlin/runtime/http/operation/ReceiveMiddleware { public fun ()V public fun handle (Laws/smithy/kotlin/runtime/http/operation/OperationRequest;Laws/smithy/kotlin/runtime/io/Handler;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/runtime/protocol/http-client/common/src/aws/smithy/kotlin/runtime/http/interceptors/SmokeTestsInterceptor.kt b/runtime/protocol/http-client/common/src/aws/smithy/kotlin/runtime/http/interceptors/SmokeTestsInterceptor.kt new file mode 100644 index 000000000..63848ffe2 --- /dev/null +++ b/runtime/protocol/http-client/common/src/aws/smithy/kotlin/runtime/http/interceptors/SmokeTestsInterceptor.kt @@ -0,0 +1,29 @@ +package aws.smithy.kotlin.runtime.http.interceptors + +import aws.smithy.kotlin.runtime.InternalApi +import aws.smithy.kotlin.runtime.client.ProtocolResponseInterceptorContext +import aws.smithy.kotlin.runtime.http.request.HttpRequest +import aws.smithy.kotlin.runtime.http.response.HttpResponse + +/** + * Interceptor for smoke test runner clients. + * + * A passing test is not predicated on an SDK being able to parse the server response received, it’s based on the + * response’s HTTP status code. + */ +@InternalApi +public class SmokeTestsInterceptor : HttpInterceptor { + override fun readBeforeDeserialization(context: ProtocolResponseInterceptorContext) { + val status = context.protocolResponse.status.value + when (status) { + in 400..599 -> throw SmokeTestsFailureException() + in 200..299 -> throw SmokeTestsSuccessException() + else -> throw SmokeTestsUnexpectedException() + } + } +} + +@InternalApi public class SmokeTestsFailureException : Exception() + +@InternalApi public class SmokeTestsSuccessException : Exception() +private class SmokeTestsUnexpectedException : Exception() diff --git a/runtime/runtime-core/api/runtime-core.api b/runtime/runtime-core/api/runtime-core.api index add320979..d1ba553de 100644 --- a/runtime/runtime-core/api/runtime-core.api +++ b/runtime/runtime-core/api/runtime-core.api @@ -2044,6 +2044,10 @@ public final class aws/smithy/kotlin/runtime/retries/policy/SuccessAcceptor : aw public final fun getSuccess ()Z } +public final class aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctionsJVMKt { + public static final fun exitProcess (I)Ljava/lang/Void; +} + public final class aws/smithy/kotlin/runtime/text/Scanner { public fun (Ljava/lang/String;)V public final fun getText ()Ljava/lang/String; diff --git a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctions.kt b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctions.kt new file mode 100644 index 000000000..f996632e9 --- /dev/null +++ b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctions.kt @@ -0,0 +1,3 @@ +package aws.smithy.kotlin.runtime.smoketests + +public expect fun exitProcess(status: Int): Nothing diff --git a/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctionsJVM.kt b/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctionsJVM.kt new file mode 100644 index 000000000..b11912df7 --- /dev/null +++ b/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctionsJVM.kt @@ -0,0 +1,5 @@ +package aws.smithy.kotlin.runtime.smoketests + +import kotlin.system.exitProcess + +public actual fun exitProcess(status: Int): Nothing = exitProcess(status) diff --git a/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctionsNative.kt b/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctionsNative.kt new file mode 100644 index 000000000..b11912df7 --- /dev/null +++ b/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/smoketests/SmokeTestsFunctionsNative.kt @@ -0,0 +1,5 @@ +package aws.smithy.kotlin.runtime.smoketests + +import kotlin.system.exitProcess + +public actual fun exitProcess(status: Int): Nothing = exitProcess(status)