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..81b14249 --- /dev/null +++ b/sdk/src/main/kotlin/com/pulumi/kotlin/Output.kt @@ -0,0 +1,76 @@ +@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() + val value = context.format() + val output = if (context.isKnown) Output.of(value) else unknownOutput() + return if (context.isSecret) output.asSecret() else output + } +} + +/** + * 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, + 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? { + 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 + } + if (!outputData.isKnown) { + this@OutputInterpolationContext.isKnown = false + } + + return value + } +} + +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..192351ae --- /dev/null +++ b/sdk/src/test/kotlin/com/pulumi/kotlin/OutputTest.kt @@ -0,0 +1,163 @@ +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.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 = unknownOutput() + val output3 = Output.of("value3") + + // 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.ofSecret("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()) + 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 = unknownOutput() + val output2 = Output.ofSecret("value2") + val output3 = unknownOutput() + + // 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 outputs that are both unknown and secret`() { + // given + val output1 = Output.of("value1") + val output2 = unknownOutput().asSecret() + val output3 = Output.of("value3") + + // 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 unknownOutput(): Output { + return OutputInternal(CompletableFuture.completedFuture(OutputData.unknown())) + } + + 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() + } +}