diff --git a/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/CompilationTest.kt b/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/CompilationTest.kt index 44a8c7c..48efe96 100644 --- a/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/CompilationTest.kt +++ b/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/CompilationTest.kt @@ -33,6 +33,9 @@ class CompilationTest { @Test fun `delegated_vars compiles`() = compile("delegated_vars/delegated_vars.connekt.kts") + @Test + fun `ttl_dsl compiles`() = compile("ttl/ttl_dsl.connekt.kts") + @Test fun `import_helper compiles`() { val tempDir = kotlin.io.path.createTempDirectory("connekt-compile-test").toFile() diff --git a/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/IntegrationScriptRunner.kt b/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/IntegrationScriptRunner.kt index f50449c..dcd9bf3 100644 --- a/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/IntegrationScriptRunner.kt +++ b/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/IntegrationScriptRunner.kt @@ -2,6 +2,7 @@ package io.amplicode.connekt.integration import io.amplicode.connekt.BaseNonColorPrinter import io.amplicode.connekt.ConnektAuthExtensionsImpl +import io.amplicode.connekt.Printer import io.amplicode.connekt.RawOutputConnektInterceptor import io.amplicode.connekt.SystemOutPrinter import io.amplicode.connekt.auth.OAuthRunner @@ -137,9 +138,9 @@ fun createIntegrationContext( environmentStore: EnvironmentStore = NoopEnvironmentStore, storage: Storage = InMemoryStorage(), builderFactory: ((ConnektContext) -> ConnektBuilderFactory)? = null, + printer: Printer = SystemOutPrinter, configure: ConnektContext.() -> Unit = {} ): ConnektContext { - val printer = SystemOutPrinter return createConnektContext( storage = storage, environmentStore = environmentStore, diff --git a/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/TtlIntegrationTest.kt b/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/TtlIntegrationTest.kt new file mode 100644 index 0000000..fcc55c7 --- /dev/null +++ b/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/TtlIntegrationTest.kt @@ -0,0 +1,253 @@ +package io.amplicode.connekt.integration + +import io.amplicode.connekt.BaseNonColorPrinter +import io.amplicode.connekt.context.ValuesEnvironmentStore +import io.amplicode.connekt.context.execution.ExecutionScenario +import io.amplicode.connekt.context.persistence.InMemoryStorage +import io.amplicode.connekt.context.persistence.Storage +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import kotlin.reflect.typeOf + +class TtlIntegrationTest : IntegrationTest() { + + @Test + fun `cached value is reused while ttl is valid`() { + val storage = InMemoryStorage() + + runTtlScript(storage, counterId = "ttl-valid", ttlMillis = 3_600_000) + val first = storage.token() + + runTtlScript(storage, counterId = "ttl-valid", ttlMillis = 3_600_000) + val second = storage.token() + + assertEquals(first, second) + } + + @Test + fun `cached value is refreshed after ttl expires`() { + val storage = InMemoryStorage() + + runTtlScript(storage, counterId = "ttl-expired", ttlMillis = 0) + val first = storage.token() + + runTtlScript(storage, counterId = "ttl-expired", ttlMillis = 0) + val second = storage.token() + + assertNotEquals(first, second) + } + + @Test + fun `ttl derived from response is reused while valid`() { + val storage = InMemoryStorage() + + runFromResponseScript(storage, expiresIn = 3600) + val first = storage.token() + + runFromResponseScript(storage, expiresIn = 3600) + val second = storage.token() + + assertEquals(first, second) + } + + @Test + fun `ttl derived from response refreshes when expired`() { + val storage = InMemoryStorage() + + runFromResponseScript(storage, expiresIn = 0) + val first = storage.token() + + runFromResponseScript(storage, expiresIn = 0) + val second = storage.token() + + assertNotEquals(first, second) + } + + @Test + fun `ttl derived from header is reused while valid`() { + val storage = InMemoryStorage() + + runFromHeaderScript(storage, expiresIn = 3600) + val first = storage.token() + + runFromHeaderScript(storage, expiresIn = 3600) + val second = storage.token() + + assertEquals(first, second) + } + + @Test + fun `ttl derived from header refreshes when expired`() { + val storage = InMemoryStorage() + + runFromHeaderScript(storage, expiresIn = 0) + val first = storage.token() + + runFromHeaderScript(storage, expiresIn = 0) + val second = storage.token() + + assertNotEquals(first, second) + } + + @Test + fun `useCase value is reused while ttl is valid`() { + val storage = InMemoryStorage() + + runUseCaseScript(storage, counterId = "uc-valid", ttlMillis = 3_600_000) + val first = storage.token() + + runUseCaseScript(storage, counterId = "uc-valid", ttlMillis = 3_600_000) + val second = storage.token() + + assertEquals(first, second) + } + + @Test + fun `useCase value is refreshed after ttl expires`() { + val storage = InMemoryStorage() + + runUseCaseScript(storage, counterId = "uc-expired", ttlMillis = 0) + val first = storage.token() + + runUseCaseScript(storage, counterId = "uc-expired", ttlMillis = 0) + val second = storage.token() + + assertNotEquals(first, second) + } + + @Test + fun `value without ttl is cached indefinitely`() { + val storage = InMemoryStorage() + + runAbsentTtlScript(storage, counterId = "ttl-absent") + val first = storage.token() + + runAbsentTtlScript(storage, counterId = "ttl-absent") + val second = storage.token() + + assertEquals(first, second) + } + + private fun runFromResponseScript(storage: Storage, expiresIn: Long) { + val env = ValuesEnvironmentStore( + mapOf( + "host" to host, + "expiresIn" to expiresIn.toString() + ) + ) + runScriptFile( + scriptFile("ttl/ttl_from_response.connekt.kts"), + createIntegrationContext(env, storage), + ExecutionScenario.SingleExecution("echoed") + ).assertSuccess() + } + + private fun runFromHeaderScript(storage: Storage, expiresIn: Long) { + val env = ValuesEnvironmentStore( + mapOf( + "host" to host, + "expiresIn" to expiresIn.toString() + ) + ) + runScriptFile( + scriptFile("ttl/ttl_from_header.connekt.kts"), + createIntegrationContext(env, storage), + ExecutionScenario.SingleExecution("echoed") + ).assertSuccess() + } + + private fun runUseCaseScript(storage: Storage, counterId: String, ttlMillis: Long) { + val env = ValuesEnvironmentStore( + mapOf( + "host" to host, + "counterId" to counterId, + "ttlMillis" to ttlMillis.toString() + ) + ) + runScriptFile( + scriptFile("ttl/ttl_usecase.connekt.kts"), + createIntegrationContext(env, storage), + ExecutionScenario.SingleExecution("echoed") + ).assertSuccess() + } + + private fun runAbsentTtlScript(storage: Storage, counterId: String) { + val env = ValuesEnvironmentStore(mapOf("host" to host, "counterId" to counterId)) + runScriptFile( + scriptFile("ttl/ttl_absent.connekt.kts"), + createIntegrationContext(env, storage), + ExecutionScenario.SingleExecution("echoed") + ).assertSuccess() + } + + @Test + fun `expired cache refresh is reported in output`() { + val storage = InMemoryStorage() + runTtlScript(storage, counterId = "ttl-log", ttlMillis = 0) + + val output = StringBuilder() + val capturingPrinter = object : BaseNonColorPrinter() { + override fun print(s: String) { + output.append(s) + } + } + val env = ValuesEnvironmentStore( + mapOf("host" to host, "counterId" to "ttl-log", "ttlMillis" to "0") + ) + runScriptFile( + scriptFile("ttl/ttl.connekt.kts"), + createIntegrationContext(env, storage, printer = capturingPrinter), + ExecutionScenario.SingleExecution("echoed") + ).assertSuccess() + + assertTrue( + output.contains("has expired, re-executing"), + "Expected TTL-expiry message in output, got:\n$output" + ) + } + + @Test + fun `expired useCase refresh is reported in output`() { + val storage = InMemoryStorage() + runUseCaseScript(storage, counterId = "uc-log", ttlMillis = 0) + + val output = StringBuilder() + val capturingPrinter = object : BaseNonColorPrinter() { + override fun print(s: String) { + output.append(s) + } + } + val env = ValuesEnvironmentStore( + mapOf("host" to host, "counterId" to "uc-log", "ttlMillis" to "0") + ) + runScriptFile( + scriptFile("ttl/ttl_usecase.connekt.kts"), + createIntegrationContext(env, storage, printer = capturingPrinter), + ExecutionScenario.SingleExecution("echoed") + ).assertSuccess() + + assertTrue( + output.contains("has expired, re-executing useCase"), + "Expected useCase TTL-expiry message in output, got:\n$output" + ) + } + + private fun runTtlScript(storage: Storage, counterId: String, ttlMillis: Long) { + val env = ValuesEnvironmentStore( + mapOf( + "host" to host, + "counterId" to counterId, + "ttlMillis" to ttlMillis.toString() + ) + ) + runScriptFile( + scriptFile("ttl/ttl.connekt.kts"), + createIntegrationContext(env, storage), + ExecutionScenario.SingleExecution("echoed") + ).assertSuccess() + } + + private fun Storage.token(): String? = getValue("token", typeOf()) +} diff --git a/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/server/IntegrationRouting.kt b/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/server/IntegrationRouting.kt index 3118d30..d3f3d2d 100644 --- a/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/server/IntegrationRouting.kt +++ b/connekt-integration-tests/src/test/java/io/amplicode/connekt/integration/server/IntegrationRouting.kt @@ -32,6 +32,7 @@ fun Application.configureIntegrationRouting() { } jsonApi() counterApi() + tokenApi() echoApi() cookiesApi() oauthApi() @@ -129,6 +130,20 @@ private fun Routing.counterApi() { } } +private fun Routing.tokenApi() { + val counter = AtomicInteger() + post("/token") { + val expiresIn = call.request.queryParameters["expires_in"]?.toLong() ?: 3600L + val token = "tok-${counter.incrementAndGet()}" + call.response.headers.append("X-Token-Expires-In", expiresIn.toString()) + call.respondText( + //language=json + """{"access_token": "$token", "expires_in": $expiresIn}""", + contentType = ContentType.Application.Json + ) + } +} + @Serializable data class SetCookieRequest(val cookieRequests: List) diff --git a/connekt-integration-tests/src/test/resources/scripts/ttl/ttl.connekt.kts b/connekt-integration-tests/src/test/resources/scripts/ttl/ttl.connekt.kts new file mode 100644 index 0000000..485e18b --- /dev/null +++ b/connekt-integration-tests/src/test/resources/scripts/ttl/ttl.connekt.kts @@ -0,0 +1,15 @@ +import kotlin.time.Duration.Companion.milliseconds + +val host: String by env +val counterId: String by env +val ttlMillis: Long by env + +val token by POST("$host/counter/$counterId/inc") { + ttl(ttlMillis.milliseconds) +} then { + body!!.string() +} + +val echoed by GET("$host/echo-query-params") { + queryParam("token", token) +} diff --git a/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_absent.connekt.kts b/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_absent.connekt.kts new file mode 100644 index 0000000..f99c2eb --- /dev/null +++ b/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_absent.connekt.kts @@ -0,0 +1,10 @@ +val host: String by env +val counterId: String by env + +val token by POST("$host/counter/$counterId/inc") then { + body!!.string() +} + +val echoed by GET("$host/echo-query-params") { + queryParam("token", token) +} diff --git a/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_dsl.connekt.kts b/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_dsl.connekt.kts new file mode 100644 index 0000000..1f7625f --- /dev/null +++ b/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_dsl.connekt.kts @@ -0,0 +1,28 @@ +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +val host: String by env + +val fixed by POST("$host/auth/token") { + ttl(5.minutes) +} then { + decode("$.access_token") +} + +val fromBody by POST("$host/auth/token") { + ttl { decode("$.expires_in").seconds } +} then { + decode("$.access_token") +} + +val fromHeader by POST("$host/auth/token") { + ttl { header("X-Token-Expires-In")!!.toLong().seconds } +} then { + decode("$.access_token") +} + +val useCaseValue by useCase("token via useCase") { + ttl(5.minutes) + val response by POST("$host/auth/token") + response.decode("$.access_token") +} diff --git a/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_from_header.connekt.kts b/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_from_header.connekt.kts new file mode 100644 index 0000000..ac64d5f --- /dev/null +++ b/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_from_header.connekt.kts @@ -0,0 +1,15 @@ +import kotlin.time.Duration.Companion.seconds + +val host: String by env +val expiresIn: Long by env + +val token by POST("$host/token") { + queryParam("expires_in", expiresIn) + ttl { header("X-Token-Expires-In")!!.toLong().seconds } +} then { + decode("$.access_token") +} + +val echoed by GET("$host/echo-query-params") { + queryParam("token", token) +} diff --git a/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_from_response.connekt.kts b/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_from_response.connekt.kts new file mode 100644 index 0000000..16331cf --- /dev/null +++ b/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_from_response.connekt.kts @@ -0,0 +1,15 @@ +import kotlin.time.Duration.Companion.seconds + +val host: String by env +val expiresIn: Long by env + +val token by POST("$host/token") { + queryParam("expires_in", expiresIn) + ttl { decode("$.expires_in").seconds } +} then { + decode("$.access_token") +} + +val echoed by GET("$host/echo-query-params") { + queryParam("token", token) +} diff --git a/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_usecase.connekt.kts b/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_usecase.connekt.kts new file mode 100644 index 0000000..9358022 --- /dev/null +++ b/connekt-integration-tests/src/test/resources/scripts/ttl/ttl_usecase.connekt.kts @@ -0,0 +1,15 @@ +import kotlin.time.Duration.Companion.milliseconds + +val host: String by env +val counterId: String by env +val ttlMillis: Long by env + +val token by useCase("token via useCase") { + ttl(ttlMillis.milliseconds) + val response by POST("$host/counter/$counterId/inc") + response.body!!.string() +} + +val echoed by GET("$host/echo-query-params") { + queryParam("token", token) +} diff --git a/connekt-script-definition/README.md b/connekt-script-definition/README.md index 8490063..a926e14 100644 --- a/connekt-script-definition/README.md +++ b/connekt-script-definition/README.md @@ -76,6 +76,38 @@ val userId: String by POST("https://api.example.com/users") { GET("https://api.example.com/users/$userId") ``` +### Value Caching with TTL + +A delegated value is cached indefinitely and refreshed only by re-running its request. A `ttl` makes +it expire: once elapsed, the next run re-fetches instead of returning the stale value. + +```kotlin +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +// fixed duration +val token: String by POST("$baseUrl/auth/token") { + ttl(5.minutes) +} then { decode("$.access_token") } + +// computed from the response (body via decode, headers via header) +val token: String by POST("$baseUrl/auth/token") { + ttl { decode("$.expires_in").seconds } +} then { decode("$.access_token") } +``` + +A `useCase` supports a fixed duration only: + +```kotlin +val token: String by useCase("auth") { + ttl(5.minutes) + val response by POST("$baseUrl/auth/token") + response.decode("$.access_token") +} +``` + +TTL does not refresh on `401` — for that use the [OAuth flow](#oauth2-authorization-code-flow-experimental). + ### Form Data ```kotlin diff --git a/connekt-script-definition/src/main/java/io/amplicode/connekt/ConnektBuilderImpl.kt b/connekt-script-definition/src/main/java/io/amplicode/connekt/ConnektBuilderImpl.kt index 0651877..287836c 100644 --- a/connekt-script-definition/src/main/java/io/amplicode/connekt/ConnektBuilderImpl.kt +++ b/connekt-script-definition/src/main/java/io/amplicode/connekt/ConnektBuilderImpl.kt @@ -6,7 +6,10 @@ import io.amplicode.connekt.context.StoredVariableDelegate import io.amplicode.connekt.context.execution.DeclarationCoordinates import io.amplicode.connekt.context.execution.Executable import io.amplicode.connekt.dsl.* +import java.time.Instant import kotlin.reflect.KProperty +import kotlin.time.Duration +import kotlin.time.toJavaDuration internal class ConnektBuilderImpl(private val context: ConnektContext) : ConnektBuilder, @@ -22,12 +25,16 @@ internal class ConnektBuilderImpl(private val context: ConnektContext) : } override fun useCase(name: String?, runUseCase: UseCaseBuilder.() -> T): UseCaseExecutable { + lateinit var useCaseExecutable: UseCaseExecutable val useCase = object : UseCase { override val name: String? = name - override fun perform(useCaseBuilder: UseCaseBuilder) = - useCaseBuilder.runUseCase() + override fun perform(useCaseBuilder: UseCaseBuilder): T { + val result = useCaseBuilder.runUseCase() + useCaseExecutable.captureTtl(useCaseBuilder.ttlDuration) + return result + } } - val useCaseExecutable = UseCaseExecutable(context, useCase) + useCaseExecutable = UseCaseExecutable(context, useCase) context.executionContext.registerExecutable(useCaseExecutable, name) return useCaseExecutable } @@ -58,7 +65,8 @@ internal class ConnektBuilderImpl(private val context: ConnektContext) : return StoredValueDelegate( context, executable, - storedValue::value + storedValue::value, + storedValue::expired ) } @@ -84,17 +92,21 @@ internal class ConnektBuilderImpl(private val context: ConnektContext) : ) { private val key = prop.name private val storage = context.variablesStore + private val ttlSource = requestHolder.originalExecutable as? RequestHolder + + fun expired(): Boolean = storage.isExpired(key) var value: R? - get() = storage.getValue(key, prop.returnType) + get() = if (storage.isExpired(key)) null else storage.getValue(key, prop.returnType) set(value) { storage.setValue(key, value) } init { - // update stored value on response received + // update stored value and its expiration on response received requestHolder.onResultObtained { value = it + storage.setExpiration(key, ttlSource?.expiresAt) } } } @@ -109,6 +121,7 @@ internal class ConnektBuilderImpl(private val context: ConnektContext) : init { executable.addListener { storeMap.setValue(key, it) + storeMap.setExpiration(key, executable.expiresAt) } } @@ -116,14 +129,17 @@ internal class ConnektBuilderImpl(private val context: ConnektContext) : thisRef: Any?, property: KProperty<*> ): R { - var value = storeMap.getValue(key, prop.returnType) - - if (value == null) { - value = executable.execute() - storeMap.setValue(key, value) + val expired = storeMap.isExpired(key) + if (!expired) { + storeMap.getValue(key, prop.returnType)?.let { return it } } - - return value!! + val message = if (expired) { + "Cached value for property `${property.name}` has expired, re-executing useCase" + } else { + "Initializing value for property `${property.name}`" + } + context.printer.println(message) + return executable.execute() } } @@ -141,14 +157,38 @@ class UseCaseExecutable( private val listeners: MutableList<(T) -> Unit> = mutableListOf() + private var capturedTtl: Duration? = null + + /** + * Expiration timestamp of the cached value from the last execution, or `null` if no TTL was + * configured for the useCase. Updated on every [execute]. + */ + var expiresAt: Instant? = null + private set + fun addListener(listener: (T) -> Unit) { listeners.add(listener) } + /** + * Records the fixed TTL configured inside the useCase body. Called while the useCase runs, so + * [execute] can turn it into an [expiresAt] once the strategy is known. + */ + fun captureTtl(duration: Duration?) { + capturedTtl = duration + } + override fun execute(): T { val executionStrategy = context.executionContext.getExecutionStrategy(this) + capturedTtl = null val value = executionStrategy.executeUseCase(context, useCase) + expiresAt = if (executionStrategy.performsRealRequest) { + capturedTtl?.let { Instant.now().plus(it.toJavaDuration()) } + } else { + null + } + for (listener in listeners) { listener(value) } diff --git a/connekt-script-definition/src/main/java/io/amplicode/connekt/ExecutableWithResult.kt b/connekt-script-definition/src/main/java/io/amplicode/connekt/ExecutableWithResult.kt index db3622c..c56d5dc 100644 --- a/connekt-script-definition/src/main/java/io/amplicode/connekt/ExecutableWithResult.kt +++ b/connekt-script-definition/src/main/java/io/amplicode/connekt/ExecutableWithResult.kt @@ -4,6 +4,8 @@ import io.amplicode.connekt.context.ConnektContext import io.amplicode.connekt.context.execution.Executable import io.amplicode.connekt.dsl.RequestBuilder import okhttp3.Response +import java.time.Instant +import kotlin.time.toJavaDuration /** * Provides controls to handle response data. @@ -42,12 +44,23 @@ class RequestHolder( override val originalExecutable = this + var expiresAt: Instant? = null + private set + private val executionStrategy get() = context.executionContext.getExecutionStrategy(this) override fun doExecute(): Response { val requestBuilder = requestBuilderProvider.getRequestBuilder() - val response = executionStrategy.executeRequest(context, requestBuilder) + val strategy = executionStrategy + val response = strategy.executeRequest(context, requestBuilder) + expiresAt = if (strategy.performsRealRequest) { + requestBuilder.ttlSpec?.let { spec -> + Instant.now().plus(spec.computeTtl(response).toJavaDuration()) + } + } else { + null + } return response } diff --git a/connekt-script-definition/src/main/java/io/amplicode/connekt/StoredValueDelegate.kt b/connekt-script-definition/src/main/java/io/amplicode/connekt/StoredValueDelegate.kt index 9920418..c451309 100644 --- a/connekt-script-definition/src/main/java/io/amplicode/connekt/StoredValueDelegate.kt +++ b/connekt-script-definition/src/main/java/io/amplicode/connekt/StoredValueDelegate.kt @@ -12,16 +12,24 @@ import kotlin.reflect.KProperty * logging capabilities, client information, and other utilities. * @param executableWithResult Executes the logic to generate a value when it is not already stored. * @param storedValueProvider Holds a potentially precomputed value of type T or null if not initialized. + * @param expired Reports whether a stored value existed but was dropped because its TTL elapsed, + * used only to make the re-execution reason visible in the output. */ class StoredValueDelegate( private val connektContext: ConnektContext, private val executableWithResult: ExecutableWithResult, private val storedValueProvider: () -> T?, + private val expired: () -> Boolean = { false }, ) : ValueDelegateBase() { override fun getValueImpl(thisRef: Any?, property: KProperty<*>): T { storedValueProvider()?.let { return it } - connektContext.printer.println("Initializing value for property `${property.name}`") + val message = if (expired()) { + "Cached value for property `${property.name}` has expired, re-executing request" + } else { + "Initializing value for property `${property.name}`" + } + connektContext.printer.println(message) return executableWithResult.execute() } } \ No newline at end of file diff --git a/connekt-script-definition/src/main/java/io/amplicode/connekt/context/VariablesStore.kt b/connekt-script-definition/src/main/java/io/amplicode/connekt/context/VariablesStore.kt index 4dbeca1..de02d4a 100644 --- a/connekt-script-definition/src/main/java/io/amplicode/connekt/context/VariablesStore.kt +++ b/connekt-script-definition/src/main/java/io/amplicode/connekt/context/VariablesStore.kt @@ -6,8 +6,10 @@ package io.amplicode.connekt.context import io.amplicode.connekt.context.persistence.Storage +import java.time.Instant import kotlin.reflect.KProperty import kotlin.reflect.KType +import kotlin.reflect.typeOf class VariablesStore(val values: Storage) { fun string() = DelegateProvider(values) @@ -18,6 +20,25 @@ class VariablesStore(val values: Storage) { fun setValue(name: String, value: T?) = values.setValue(name, value) fun getValue(name: String, type: KType): T? = values.getValue(name, type) + + /** + * Persists the expiration timestamp for the variable [name]. A `null` [expiresAt] clears any + * previously stored expiration, making the value cache indefinitely. + */ + fun setExpiration(name: String, expiresAt: Instant?) { + values.setValue(expirationKey(name), expiresAt?.toEpochMilli()) + } + + /** + * Returns `true` if the value stored under [name] has an expiration timestamp that already + * passed. Values with no stored expiration never expire. + */ + fun isExpired(name: String): Boolean { + val expiresAtMillis: Long = values.getValue(expirationKey(name), typeOf()) ?: return false + return System.currentTimeMillis() >= expiresAtMillis + } + + private fun expirationKey(name: String) = "$name#expiresAt" } class DelegateProvider(private val values: Storage) { diff --git a/connekt-script-definition/src/main/java/io/amplicode/connekt/context/execution/ConnektExecutionStrategy.kt b/connekt-script-definition/src/main/java/io/amplicode/connekt/context/execution/ConnektExecutionStrategy.kt index 53b45d7..5004062 100644 --- a/connekt-script-definition/src/main/java/io/amplicode/connekt/context/execution/ConnektExecutionStrategy.kt +++ b/connekt-script-definition/src/main/java/io/amplicode/connekt/context/execution/ConnektExecutionStrategy.kt @@ -37,6 +37,13 @@ interface ConnektExecutionStrategy : RequestExecutionStrategy, UseCaseExecutionS requestExecutable: ExecutableWithResult, mapFunction: io.amplicode.connekt.MapFunction ): io.amplicode.connekt.MappedRequestHolder + + /** + * Whether this strategy performs a real request whose response can be inspected. `false` for + * preview strategies (e.g. curl generation) that return a synthetic response, so TTL derived + * from the response must not be computed against it. + */ + val performsRealRequest: Boolean } /** @@ -44,6 +51,8 @@ interface ConnektExecutionStrategy : RequestExecutionStrategy, UseCaseExecutionS */ class DefaultExecutionStrategy : ConnektExecutionStrategy { + override val performsRealRequest = true + override fun executeRequest(context: ConnektContext, requestBuilder: RequestBuilder): Response { val request = requestBuilder.build() val clientConfigurer = requestBuilder.getClientConfigurer() @@ -73,6 +82,8 @@ class DefaultExecutionStrategy : ConnektExecutionStrategy { */ class CurlExecutionStrategy : ConnektExecutionStrategy { + override val performsRealRequest = false + override fun executeRequest(context: ConnektContext, requestBuilder: RequestBuilder): Response { val interceptor = simpleCurlInterceptor { command -> context.printer.println(command) diff --git a/connekt-script-definition/src/main/java/io/amplicode/connekt/context/persistence/JsonStorage.kt b/connekt-script-definition/src/main/java/io/amplicode/connekt/context/persistence/JsonStorage.kt index 5faf791..f39cfc6 100644 --- a/connekt-script-definition/src/main/java/io/amplicode/connekt/context/persistence/JsonStorage.kt +++ b/connekt-script-definition/src/main/java/io/amplicode/connekt/context/persistence/JsonStorage.kt @@ -30,6 +30,7 @@ class JsonStorage( override fun getValue(key: String, type: KType): T? { val jsonNode = data[key] ?: return null + if (jsonNode.isNull) return null val javaType = objectMapper.constructType(type.javaType) return try { objectMapper.convertValue(jsonNode, javaType) as? T diff --git a/connekt-script-definition/src/main/java/io/amplicode/connekt/dsl/RequestBuilder.kt b/connekt-script-definition/src/main/java/io/amplicode/connekt/dsl/RequestBuilder.kt index 2d72b3f..66affcf 100644 --- a/connekt-script-definition/src/main/java/io/amplicode/connekt/dsl/RequestBuilder.kt +++ b/connekt-script-definition/src/main/java/io/amplicode/connekt/dsl/RequestBuilder.kt @@ -12,10 +12,13 @@ import io.amplicode.connekt.HeaderName import io.amplicode.connekt.HeaderValue import io.amplicode.connekt.MissingPathParameterException import io.amplicode.connekt.context.ClientConfigurer +import com.jayway.jsonpath.ReadContext import okhttp3.* import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.internal.http.HttpMethod +import org.intellij.lang.annotations.Language +import kotlin.time.Duration @DslMarker annotation class ConnektDsl @@ -71,6 +74,13 @@ class RequestBuilder( private var noCookies = false private var noRedirect = false private var http2 = false + internal var ttlSpec: TtlSpec? = null + private set(value) { + require(field == null) { + "TTL already set. Use ttl() only once per request" + } + field = value + } private val requestBuilderTweaks: MutableList = mutableListOf() private val clientBuilderTweaks: MutableList = mutableListOf() @@ -184,6 +194,56 @@ class RequestBuilder( http2 = true } + /** + * Sets a fixed time-to-live for the value cached in `vars` when this request is delegated to a + * variable (via `by`). + * + * After the TTL elapses, the next access to the delegated variable re-executes the request and + * refreshes the stored value instead of returning the stale cached one. Without a TTL the cached + * value never expires and is only refreshed by an explicit re-run. + * + * @param duration How long the cached value stays valid, measured from the moment the response + * is received. + */ + fun ttl(duration: Duration) { + ttlSpec = TtlSpec { duration } + } + + /** + * Computes the TTL of the cached value from the response, for servers that report the lifetime + * themselves. The [fromResponse] block runs with the [Response] as its receiver right after the + * request completes and can read both the body (via [decode]) and headers (via + * [Response.header]): e.g. `ttl { decode("$.expires_in").seconds }` or + * `ttl { header("X-Token-Expires-In")!!.toLong().seconds }`. + * + * @param fromResponse Computes the TTL from the response. + */ + fun ttl(fromResponse: Response.() -> Duration) { + ttlSpec = TtlSpec { response -> response.fromResponse() } + } + + /** + * Parses the response body as JSON and returns a [ReadContext] for JSONPath queries. + * + * Mirrors the script-level `jsonPath()` helper so it can be used inside request-configuration + * blocks (e.g. from [ttl]), where the DSL scope hides the top-level receiver. + */ + fun Response.jsonPath(): ReadContext { + val ctx = requireNotNull(context) { + "Request context is unavailable to parse the response body" + } + return ctx.jsonContext.getReadContext(this) + } + + /** + * Deserializes the JSON response body at [path] into type [T]. + * + * Mirrors the script-level `decode()` helper so it can be used inside request-configuration + * blocks (e.g. from [ttl]), where the DSL scope hides the top-level receiver. + */ + inline fun Response.decode(@Language("JSONPath") path: String = "$"): T = + jsonPath().decode(path) + /** * Adds multiple request headers at once. * @@ -495,3 +555,10 @@ class RequestBuilder( } typealias RequestBuilderConfigurer = Request.Builder.() -> Unit + +/** + * Computes the time-to-live of a cached variable value from the executed request's [Response]. + */ +fun interface TtlSpec { + fun computeTtl(response: Response): Duration +} diff --git a/connekt-script-definition/src/main/java/io/amplicode/connekt/dsl/UseCaseBuilder.kt b/connekt-script-definition/src/main/java/io/amplicode/connekt/dsl/UseCaseBuilder.kt index 5f2e103..e09996c 100644 --- a/connekt-script-definition/src/main/java/io/amplicode/connekt/dsl/UseCaseBuilder.kt +++ b/connekt-script-definition/src/main/java/io/amplicode/connekt/dsl/UseCaseBuilder.kt @@ -2,6 +2,7 @@ package io.amplicode.connekt.dsl import okhttp3.Response import kotlin.reflect.KProperty +import kotlin.time.Duration /** * DSL context receiver for a `useCase {}` block. @@ -22,6 +23,27 @@ import kotlin.reflect.KProperty @ConnektDsl abstract class UseCaseBuilder : RequestRegistrator, JsonPathExtensionsProvider { + internal var ttlDuration: Duration? = null + private set(value) { + require(field == null) { "TTL already set. Use ttl() only once per useCase" } + field = value + } + + /** + * Sets a fixed time-to-live for the value this useCase produces when it is delegated to a + * variable (via `by`). After the TTL elapses, the next access to the variable re-runs the + * useCase and refreshes the stored value. + * + * Only a fixed [duration] is supported for a useCase: unlike a single request, a useCase returns + * its value imperatively, so a response-derived TTL has no single response to compute from. + * + * @param duration How long the produced value stays valid, measured from the moment the useCase + * finishes. + */ + fun ttl(duration: Duration) { + ttlDuration = duration + } + /** * Enables property delegation for HTTP responses using the `by` keyword. * diff --git a/connekt-script-definition/src/test/java/io/amplicode/connekt/RequestBuilderTest.kt b/connekt-script-definition/src/test/java/io/amplicode/connekt/RequestBuilderTest.kt index 3769ef9..c91f198 100644 --- a/connekt-script-definition/src/test/java/io/amplicode/connekt/RequestBuilderTest.kt +++ b/connekt-script-definition/src/test/java/io/amplicode/connekt/RequestBuilderTest.kt @@ -5,9 +5,21 @@ import okhttp3.Request import org.junit.jupiter.api.assertThrows import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds class RequestBuilderTest { + @Test + fun `ttl can be set only once per request`() { + assertThrows { + RequestBuilder("GET", "http://localhost/api", null).apply { + ttl(5.minutes) + ttl { 1.seconds } + } + } + } + @Test fun `test valid url with placeholders`() { val builder = RequestBuilder( diff --git a/connekt-script-definition/src/test/java/io/amplicode/connekt/VariablesStoreExpirationTest.kt b/connekt-script-definition/src/test/java/io/amplicode/connekt/VariablesStoreExpirationTest.kt new file mode 100644 index 0000000..472e8d9 --- /dev/null +++ b/connekt-script-definition/src/test/java/io/amplicode/connekt/VariablesStoreExpirationTest.kt @@ -0,0 +1,56 @@ +package io.amplicode.connekt + +import io.amplicode.connekt.context.VariablesStore +import io.amplicode.connekt.context.persistence.InMemoryStorage +import io.amplicode.connekt.context.persistence.Storage +import io.amplicode.connekt.context.persistence.defaultStorage +import java.time.Instant +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class VariablesStoreExpirationTest { + + @Test + fun `no stored expiration never expires - in memory`() = withStores { store -> + store.setValue("token", "value") + assertFalse(store.isExpired("token")) + } + + @Test + fun `future expiration is not expired`() = withStores { store -> + store.setValue("token", "value") + store.setExpiration("token", Instant.now().plusSeconds(3600)) + assertFalse(store.isExpired("token")) + } + + @Test + fun `past expiration is expired`() = withStores { store -> + store.setValue("token", "value") + store.setExpiration("token", Instant.now().minusSeconds(1)) + assertTrue(store.isExpired("token")) + } + + @Test + fun `null expiration clears a previously set one`() = withStores { store -> + store.setValue("token", "value") + store.setExpiration("token", Instant.now().minusSeconds(1)) + assertTrue(store.isExpired("token")) + + store.setExpiration("token", null) + assertFalse(store.isExpired("token")) + } + + /** + * Runs [test] against both storage backends so the JSON-backed path (where a cleared expiration + * is persisted as a JSON null node) is exercised alongside the in-memory one. + */ + private fun withStores(test: (VariablesStore) -> Unit) { + test(VariablesStore(InMemoryStorage())) + + val dir = createTempDirectory("connekt-ttl-store-test") + val jsonStorage: Storage = defaultStorage(dir) + jsonStorage.use { test(VariablesStore(it)) } + } +}