From f774aeab12c29d749ebc82323c8e87d189512e97 Mon Sep 17 00:00:00 2001 From: Julia Plewa Date: Sat, 23 Sep 2023 16:09:42 +0200 Subject: [PATCH 1/3] introduce output interpolation Signed-off-by: Julia Plewa --- .../main/kotlin/com/pulumi/kotlin/Common.kt | 2 + .../main/kotlin/com/pulumi/kotlin/Output.kt | 83 ++++++++ .../kotlin/com/pulumi/kotlin/OutputTest.kt | 186 ++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 sdk/src/main/kotlin/com/pulumi/kotlin/Output.kt create mode 100644 sdk/src/test/kotlin/com/pulumi/kotlin/OutputTest.kt diff --git a/sdk/src/main/kotlin/com/pulumi/kotlin/Common.kt b/sdk/src/main/kotlin/com/pulumi/kotlin/Common.kt index f42433e3..1afaff3e 100644 --- a/sdk/src/main/kotlin/com/pulumi/kotlin/Common.kt +++ b/sdk/src/main/kotlin/com/pulumi/kotlin/Common.kt @@ -1,3 +1,5 @@ +@file:Suppress("unused") + package com.pulumi.kotlin import com.pulumi.Context diff --git a/sdk/src/main/kotlin/com/pulumi/kotlin/Output.kt b/sdk/src/main/kotlin/com/pulumi/kotlin/Output.kt new file mode 100644 index 00000000..4df4255d --- /dev/null +++ b/sdk/src/main/kotlin/com/pulumi/kotlin/Output.kt @@ -0,0 +1,83 @@ +@file:Suppress("unused") + +package com.pulumi.kotlin + +import com.pulumi.core.Output +import com.pulumi.core.internal.OutputData +import com.pulumi.core.internal.OutputInternal +import kotlinx.coroutines.future.await +import java.util.concurrent.CompletableFuture + +object Output { + + /** + * Use this method to open an [OutputInterpolationContext] in which the unary plus operator can be used on outputs + * to interpolate their asynchronous values into an asynchronous string wrapped in an output. + * ```kotlin + * val myOutput = Output.of("value") + * val interpolatedString = interpolate { "The value of this output is: ${+myOutput}" } + * ``` + * + * This method is an alternative to the [Output.format] method from Java. + * ```java + * Output myOutput = Output.of("value"); + * Output interpolatedString = String.format("The value of this output is: %s", myOutput); + * ``` + * @param format a function returning a Kotlin-style interpolated string with [Output] instances marked with [OutputInterpolationContext.unaryPlus] + * @return an asynchronous [Output]-wrapped string with Kotlin-style interpolated values + */ + suspend fun interpolation(format: suspend OutputInterpolationContext.() -> String): Output { + val context = OutputInterpolationContext() + return try { + val output = Output.of(context.format()) + if (context.isSecret) { + output.asSecret() + } else { + output + } + } catch (e: UnknownOutputError) { + if (context.isSecret) { + unknownOutput().asSecret() + } else { + unknownOutput() + } + } + } +} + +/** + * The context for Kotlin-style [Output] interpolation. + * + * @property isSecret denotes whether any of the interpolated values contain a secret + */ +class OutputInterpolationContext internal constructor(var isSecret: Boolean = false) { + /** + * The unary plus operator that can be used on [Output] instances within [OutputInterpolationContext]. + * + * @return an asynchronous value of the [Output] in question converted to a string with [toString] + */ + suspend operator fun Output.unaryPlus(): String { + return interpolate() + } + + // TODO: decide if we prefer this or the unary plus + suspend fun Output.interpolate(): String { + val outputData = (this as OutputInternal) + .dataAsync + .await() + + val value = outputData.valueNullable?.toString() + + if (outputData.isSecret) { + this@OutputInterpolationContext.isSecret = true + } + + return value ?: throw UnknownOutputError() + } +} + +private class UnknownOutputError : RuntimeException("Cannot interpolate an output whose value is unknown") + +private fun unknownOutput(): Output { + return OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) +} diff --git a/sdk/src/test/kotlin/com/pulumi/kotlin/OutputTest.kt b/sdk/src/test/kotlin/com/pulumi/kotlin/OutputTest.kt new file mode 100644 index 00000000..93490c78 --- /dev/null +++ b/sdk/src/test/kotlin/com/pulumi/kotlin/OutputTest.kt @@ -0,0 +1,186 @@ +package com.pulumi.kotlin + +import com.pulumi.core.Output +import com.pulumi.core.internal.OutputData +import com.pulumi.core.internal.OutputInternal +import com.pulumi.kotlin.Output.interpolation +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import java.util.concurrent.CompletableFuture +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class OutputTest { + + @Test + fun `interpolates known outputs`() { + // given + val output1 = Output.of("value1") + val output2 = Output.of("value2") + val output3 = Output.of("value3") + + // when + val result = runBlocking { + interpolation { + "output1: ${+output1}, output2: ${+output2}, output3: ${+output3}" + } + } + + // then + assertEquals("output1: value1, output2: value2, output3: value3", result.getValue()) + assertFalse(result.isSecret()) + assertTrue(result.isKnown()) + + val javaResult = Output.format("output1: %s, output2: %s, output3: %s", output1, output2, output3) + assertEquals(javaResult.getValue(), result.getValue()) + assertEquals(javaResult.isKnown(), result.isKnown()) + assertEquals(javaResult.isSecret(), result.isSecret()) + } + + @Test + fun `interpolates unknown outputs`() { + // given + val output1 = Output.of("value1") + val output2 = Output.of("value2") + val output3 = OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) + + // when + val result = runBlocking { + interpolation { + "output1: ${+output1}, output2: ${+output2}, output3: ${+output3}" + } + } + + // then + assertEquals(null, result.getValue()) + assertFalse(result.isKnown()) + assertFalse(result.isSecret()) + + val javaResult = Output.format("output1: %s, output2: %s, output3: %s", output1, output2, output3) + assertEquals(javaResult.getValue(), result.getValue()) + assertEquals(javaResult.isKnown(), result.isKnown()) + assertEquals(javaResult.isSecret(), result.isSecret()) + } + + @Test + fun `interpolates secret outputs`() { + // given + val output1 = Output.of("value1") + val output2 = Output.of("value2") + val output3 = Output.ofSecret("value3") + + // when + val result = runBlocking { + interpolation { + "output1: ${+output1}, output2: ${+output2}, output3: ${+output3}" + } + } + + // then + assertEquals("output1: value1, output2: value2, output3: value3", result.getValue()) + assertTrue(result.isSecret()) + assertTrue(result.isKnown()) + + val javaResult = Output.format("output1: %s, output2: %s, output3: %s", output1, output2, output3) + assertEquals(javaResult.getValue(), result.getValue()) + assertEquals(javaResult.isKnown(), result.isKnown()) + assertEquals(javaResult.isSecret(), result.isSecret()) + } + + @Test + fun `interpolates unknown and secret outputs`() { + // given + val output1 = Output.of("value1") + val output2 = Output.ofSecret("value2") + val output3 = OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) + + // when + val result = runBlocking { + interpolation { + "output1: ${+output1}, output2: ${+output2}, output3: ${+output3}" + } + } + + // then + assertEquals(null, result.getValue()) + assertFalse(result.isKnown()) + assertTrue(result.isSecret()) + + val javaResult = Output.format("output1: %s, output2: %s, output3: %s", output1, output2, output3) + assertEquals(javaResult.getValue(), result.getValue()) + assertEquals(javaResult.isKnown(), result.isKnown()) + assertEquals(javaResult.isSecret(), result.isSecret()) + } + + @Test + fun `interpolates unknown and secret outputs as not secret if an unknown output appears before a secret output`() { + // given + val output1 = OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) + val output2 = Output.ofSecret("value2") + val output3 = Output.of("value1") + + // when + val result = runBlocking { + interpolation { + "output1: ${+output1}, output2: ${+output2}, output3: ${+output3}" + } + } + + // then + assertEquals(null, result.getValue()) + assertFalse(result.isKnown()) + assertFalse(result.isSecret()) // note: this isn't the desired behavior + + val javaResult = Output.format("output1: %s, output2: %s, output3: %s", output1, output2, output3) + assertEquals(javaResult.getValue(), result.getValue()) + assertEquals(javaResult.isKnown(), result.isKnown()) + assertNotEquals(javaResult.isSecret(), result.isSecret()) // note: this isn't the desired behavior + } + + @Test + fun `interpolates outputs that are both unknown and secret`() { + // given + val output1 = Output.of("value1") + val output2 = Output.ofSecret("value2") + val output3 = + (OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) as Output).asSecret() + + // when + val result = runBlocking { + interpolation { + "output1: ${+output1}, output2: ${+output2}, output3: ${+output3}" + } + } + + // then + assertEquals(null, result.getValue()) + assertFalse(result.isKnown()) + assertTrue(result.isSecret()) + + val javaResult = Output.format("output1: %s, output2: %s, output3: %s", output1, output2, output3) + assertEquals(javaResult.getValue(), result.getValue()) + assertEquals(javaResult.isKnown(), result.isKnown()) + assertEquals(javaResult.isSecret(), result.isSecret()) + } + + private fun Output.getValue(): String? { + return (this as OutputInternal) + .dataAsync + .get() + .valueNullable + } + + private fun Output.isKnown(): Boolean { + return (this as OutputInternal) + .isKnown + .get() + } + + private fun Output.isSecret(): Boolean { + return (this as OutputInternal) + .isSecret + .get() + } +} \ No newline at end of file From 2d4c7e43a424660bcd5e79c79fe44ae3c852b3f8 Mon Sep 17 00:00:00 2001 From: Julia Plewa Date: Sat, 23 Sep 2023 16:24:11 +0200 Subject: [PATCH 2/3] fix secret + unknown output case Signed-off-by: Julia Plewa --- .../main/kotlin/com/pulumi/kotlin/Output.kt | 37 ++++++++----------- .../kotlin/com/pulumi/kotlin/OutputTest.kt | 27 +------------- 2 files changed, 16 insertions(+), 48 deletions(-) diff --git a/sdk/src/main/kotlin/com/pulumi/kotlin/Output.kt b/sdk/src/main/kotlin/com/pulumi/kotlin/Output.kt index 4df4255d..81b14249 100644 --- a/sdk/src/main/kotlin/com/pulumi/kotlin/Output.kt +++ b/sdk/src/main/kotlin/com/pulumi/kotlin/Output.kt @@ -11,13 +11,13 @@ import java.util.concurrent.CompletableFuture object Output { /** - * Use this method to open an [OutputInterpolationContext] in which the unary plus operator can be used on outputs + * Use this method to open an [OutputInterpolationContext] in which the unary plus operator can be used on outputs * to interpolate their asynchronous values into an asynchronous string wrapped in an output. * ```kotlin * val myOutput = Output.of("value") * val interpolatedString = interpolate { "The value of this output is: ${+myOutput}" } * ``` - * + * * This method is an alternative to the [Output.format] method from Java. * ```java * Output myOutput = Output.of("value"); @@ -28,20 +28,9 @@ object Output { */ suspend fun interpolation(format: suspend OutputInterpolationContext.() -> String): Output { val context = OutputInterpolationContext() - return try { - val output = Output.of(context.format()) - if (context.isSecret) { - output.asSecret() - } else { - output - } - } catch (e: UnknownOutputError) { - if (context.isSecret) { - unknownOutput().asSecret() - } else { - unknownOutput() - } - } + val value = context.format() + val output = if (context.isKnown) Output.of(value) else unknownOutput() + return if (context.isSecret) output.asSecret() else output } } @@ -50,18 +39,21 @@ object Output { * * @property isSecret denotes whether any of the interpolated values contain a secret */ -class OutputInterpolationContext internal constructor(var isSecret: Boolean = false) { +class OutputInterpolationContext internal constructor( + var isSecret: Boolean = false, + var isKnown: Boolean = true, +) { /** * The unary plus operator that can be used on [Output] instances within [OutputInterpolationContext]. * * @return an asynchronous value of the [Output] in question converted to a string with [toString] */ - suspend operator fun Output.unaryPlus(): String { + suspend operator fun Output.unaryPlus(): String? { return interpolate() } // TODO: decide if we prefer this or the unary plus - suspend fun Output.interpolate(): String { + suspend fun Output.interpolate(): String? { val outputData = (this as OutputInternal) .dataAsync .await() @@ -71,13 +63,14 @@ class OutputInterpolationContext internal constructor(var isSecret: Boolean = fa if (outputData.isSecret) { this@OutputInterpolationContext.isSecret = true } + if (!outputData.isKnown) { + this@OutputInterpolationContext.isKnown = false + } - return value ?: throw UnknownOutputError() + return value } } -private class UnknownOutputError : RuntimeException("Cannot interpolate an output whose value is unknown") - private fun unknownOutput(): Output { return OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) } diff --git a/sdk/src/test/kotlin/com/pulumi/kotlin/OutputTest.kt b/sdk/src/test/kotlin/com/pulumi/kotlin/OutputTest.kt index 93490c78..7f6211c5 100644 --- a/sdk/src/test/kotlin/com/pulumi/kotlin/OutputTest.kt +++ b/sdk/src/test/kotlin/com/pulumi/kotlin/OutputTest.kt @@ -92,7 +92,7 @@ class OutputTest { @Test fun `interpolates unknown and secret outputs`() { // given - val output1 = Output.of("value1") + val output1 = OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) val output2 = Output.ofSecret("value2") val output3 = OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) @@ -114,31 +114,6 @@ class OutputTest { assertEquals(javaResult.isSecret(), result.isSecret()) } - @Test - fun `interpolates unknown and secret outputs as not secret if an unknown output appears before a secret output`() { - // given - val output1 = OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) - val output2 = Output.ofSecret("value2") - val output3 = Output.of("value1") - - // when - val result = runBlocking { - interpolation { - "output1: ${+output1}, output2: ${+output2}, output3: ${+output3}" - } - } - - // then - assertEquals(null, result.getValue()) - assertFalse(result.isKnown()) - assertFalse(result.isSecret()) // note: this isn't the desired behavior - - val javaResult = Output.format("output1: %s, output2: %s, output3: %s", output1, output2, output3) - assertEquals(javaResult.getValue(), result.getValue()) - assertEquals(javaResult.isKnown(), result.isKnown()) - assertNotEquals(javaResult.isSecret(), result.isSecret()) // note: this isn't the desired behavior - } - @Test fun `interpolates outputs that are both unknown and secret`() { // given From 368bc34e9e9d08580ec5803bfdb8051cff70c93a Mon Sep 17 00:00:00 2001 From: Julia Plewa Date: Sat, 23 Sep 2023 16:39:00 +0200 Subject: [PATCH 3/3] update OutputTest Signed-off-by: Julia Plewa --- .../kotlin/com/pulumi/kotlin/OutputTest.kt | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/sdk/src/test/kotlin/com/pulumi/kotlin/OutputTest.kt b/sdk/src/test/kotlin/com/pulumi/kotlin/OutputTest.kt index 7f6211c5..192351ae 100644 --- a/sdk/src/test/kotlin/com/pulumi/kotlin/OutputTest.kt +++ b/sdk/src/test/kotlin/com/pulumi/kotlin/OutputTest.kt @@ -9,7 +9,6 @@ import org.junit.jupiter.api.Test import java.util.concurrent.CompletableFuture import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotEquals import kotlin.test.assertTrue class OutputTest { @@ -43,8 +42,8 @@ class OutputTest { fun `interpolates unknown outputs`() { // given val output1 = Output.of("value1") - val output2 = Output.of("value2") - val output3 = OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) + val output2 = unknownOutput() + val output3 = Output.of("value3") // when val result = runBlocking { @@ -68,8 +67,8 @@ class OutputTest { fun `interpolates secret outputs`() { // given val output1 = Output.of("value1") - val output2 = Output.of("value2") - val output3 = Output.ofSecret("value3") + val output2 = Output.ofSecret("value2") + val output3 = Output.of("value3") // when val result = runBlocking { @@ -92,9 +91,9 @@ class OutputTest { @Test fun `interpolates unknown and secret outputs`() { // given - val output1 = OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) + val output1 = unknownOutput() val output2 = Output.ofSecret("value2") - val output3 = OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) + val output3 = unknownOutput() // when val result = runBlocking { @@ -118,9 +117,8 @@ class OutputTest { fun `interpolates outputs that are both unknown and secret`() { // given val output1 = Output.of("value1") - val output2 = Output.ofSecret("value2") - val output3 = - (OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) as Output).asSecret() + val output2 = unknownOutput().asSecret() + val output3 = Output.of("value3") // when val result = runBlocking { @@ -140,6 +138,10 @@ class OutputTest { assertEquals(javaResult.isSecret(), result.isSecret()) } + private fun unknownOutput(): Output { + return OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) + } + private fun Output.getValue(): String? { return (this as OutputInternal) .dataAsync @@ -158,4 +160,4 @@ class OutputTest { .isSecret .get() } -} \ No newline at end of file +}