refactor: inject default @param values into hostArgs and cache compiled scripts for run execution
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
// @desc: hello
|
// @desc: hello
|
||||||
// @timeout: 10s
|
// @timeout: 10s
|
||||||
// @param: name | default=world | desc=hello <name> | required=false
|
// @param: name | default=world | desc=hello <name> | required=false
|
||||||
// @param: upper | default=false | desc=upper text | required=true
|
// @param: upper | default=true | desc=upper text | required=false
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
val args: Array<String> = emptyArray()
|
val args: Array<String> = emptyArray()
|
||||||
@@ -13,7 +13,7 @@ val kv =
|
|||||||
}.toMap()
|
}.toMap()
|
||||||
|
|
||||||
val name = kv["name"] ?: "world"
|
val name = kv["name"] ?: "world"
|
||||||
val upper = (kv["upper"]!!).toBoolean()
|
val upper = (kv["upper"] ?: "false").toBoolean()
|
||||||
val message = "Hello, $name @ ${LocalDateTime.now()}"
|
val message = "Hello, $name @ ${LocalDateTime.now()}"
|
||||||
|
|
||||||
println(if (upper) message.uppercase() else message)
|
println(if (upper) message.uppercase() else message)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost
|
|||||||
private val scriptingHost = BasicJvmScriptingHost()
|
private val scriptingHost = BasicJvmScriptingHost()
|
||||||
private val evalLock = Any()
|
private val evalLock = Any()
|
||||||
private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata
|
private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata
|
||||||
|
private val compiledScriptCache = ConcurrentHashMap<String, Pair<String, CompiledScript>>() // key -> stamp, compiled script
|
||||||
|
|
||||||
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
|
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
|
||||||
private val argsDeclarationRegex = Regex("""^\s*val\s+args\s*:\s*Array<String>\s*=\s*emptyArray\(\)\s*$""")
|
private val argsDeclarationRegex = Regex("""^\s*val\s+args\s*:\s*Array<String>\s*=\s*emptyArray\(\)\s*$""")
|
||||||
@@ -81,27 +82,11 @@ private fun compilationConfiguration(explicitCp: List<File>?): ScriptCompilation
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun escapeKotlinString(value: String): String = buildString(value.length) {
|
|
||||||
value.forEach { ch ->
|
|
||||||
when (ch) {
|
|
||||||
'\\' -> append("\\\\")
|
|
||||||
'"' -> append("\\\"")
|
|
||||||
'\n' -> append("\\n")
|
|
||||||
'\r' -> append("\\r")
|
|
||||||
'\t' -> append("\\t")
|
|
||||||
else -> append(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun argsInitializer(args: List<String>): String =
|
|
||||||
"val args: Array<String> = arrayOf(${args.joinToString(",") { "\"${escapeKotlinString(it)}\"" }})"
|
|
||||||
|
|
||||||
private fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}"
|
private fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}"
|
||||||
|
|
||||||
private fun injectArgsDeclaration(scriptContent: String, args: List<String>): String {
|
private fun injectArgsBridgeDeclaration(scriptContent: String): String {
|
||||||
val lines = scriptContent.lines()
|
val lines = scriptContent.lines()
|
||||||
val injected = argsInitializer(args)
|
val injected = "val args: Array<String> = hostArgs"
|
||||||
var replaced = false
|
var replaced = false
|
||||||
val result = lines.map { line ->
|
val result = lines.map { line ->
|
||||||
if (!replaced && argsDeclarationRegex.matches(line)) {
|
if (!replaced && argsDeclarationRegex.matches(line)) {
|
||||||
@@ -114,6 +99,24 @@ private fun injectArgsDeclaration(scriptContent: String, args: List<String>): St
|
|||||||
return result.joinToString("\n")
|
return result.joinToString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseArgEntry(raw: String): Pair<String, String>? {
|
||||||
|
val idx = raw.indexOf('=')
|
||||||
|
if (idx <= 0) return null
|
||||||
|
return raw.substring(0, idx) to raw.substring(idx + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyDefaultArgs(metadata: ScriptMetadata, requestArgs: List<String>): List<String> {
|
||||||
|
val existingKeys = requestArgs.mapNotNull { parseArgEntry(it)?.first }.toMutableSet()
|
||||||
|
val merged = requestArgs.toMutableList()
|
||||||
|
metadata.params.forEach { param ->
|
||||||
|
if (!existingKeys.contains(param.name) && param.defaultValue != null) {
|
||||||
|
merged += "${param.name}=${param.defaultValue}"
|
||||||
|
existingKeys += param.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
|
private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
|
||||||
var description: String? = null
|
var description: String? = null
|
||||||
var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS
|
var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS
|
||||||
@@ -259,9 +262,35 @@ private fun metadataForFile(scriptFile: File, scriptContent: String): ScriptMeta
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun evalSource(source: SourceCode): ResultWithDiagnostics<EvaluationResult> {
|
private fun compileSource(source: SourceCode): ResultWithDiagnostics<CompiledScript> {
|
||||||
val explicitCp = explicitClasspathFromEnv()
|
val explicitCp = explicitClasspathFromEnv()
|
||||||
return scriptingHost.eval(source, compilationConfiguration(explicitCp), null)
|
return runBlocking {
|
||||||
|
scriptingHost.compiler(source, compilationConfiguration(explicitCp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun evalCompiled(compiledScript: CompiledScript, requestContext: ScriptRequestContext): ResultWithDiagnostics<EvaluationResult> {
|
||||||
|
val evaluationConfiguration = ScriptEvaluationConfiguration {
|
||||||
|
constructorArgs(requestContext.args.toTypedArray())
|
||||||
|
}
|
||||||
|
return runBlocking {
|
||||||
|
scriptingHost.evaluator(compiledScript, evaluationConfiguration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compiledScriptFor(scriptFile: File, preparedContent: String): ResultWithDiagnostics<CompiledScript> {
|
||||||
|
val key = scriptFile.canonicalPath
|
||||||
|
val stamp = scriptStamp(scriptFile)
|
||||||
|
val cached = compiledScriptCache[key]
|
||||||
|
if (cached != null && cached.first == stamp) {
|
||||||
|
return cached.second.asSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
val compiled = compileSource(preparedContent.toScriptSource(scriptFile.name))
|
||||||
|
if (compiled is ResultWithDiagnostics.Success) {
|
||||||
|
compiledScriptCache[key] = stamp to compiled.value
|
||||||
|
}
|
||||||
|
return compiled
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ScriptExecutionResult(
|
data class ScriptExecutionResult(
|
||||||
@@ -281,6 +310,7 @@ fun cachedMetadata(scriptFile: File): ScriptMetadata? {
|
|||||||
|
|
||||||
fun removeCachedMetadata(scriptFile: File) {
|
fun removeCachedMetadata(scriptFile: File) {
|
||||||
metadataCache.remove(scriptFile.canonicalPath)
|
metadataCache.remove(scriptFile.canonicalPath)
|
||||||
|
compiledScriptCache.remove(scriptFile.canonicalPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMetadataFromComments(scriptFile: File): ScriptMetadata {
|
fun loadMetadataFromComments(scriptFile: File): ScriptMetadata {
|
||||||
@@ -316,16 +346,23 @@ fun evalAndCapture(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.map { it.name }
|
.map { it.name }
|
||||||
|
val effectiveArgs = applyDefaultArgs(metadata, requestContext.args)
|
||||||
|
|
||||||
val injected = injectArgsDeclaration(original, requestContext.args)
|
val injected = injectArgsBridgeDeclaration(original)
|
||||||
val result = evalSource(injected.toScriptSource(scriptFile.name))
|
val compilationResult = compiledScriptFor(scriptFile, injected)
|
||||||
val returnValueError = (result as? ResultWithDiagnostics.Success)
|
val evaluationResult = if (compilationResult is ResultWithDiagnostics.Success) {
|
||||||
|
evalCompiled(compilationResult.value, requestContext.copy(args = effectiveArgs))
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val reports = evaluationResult?.reports ?: compilationResult.reports
|
||||||
|
val returnValueError = (evaluationResult as? ResultWithDiagnostics.Success)
|
||||||
?.value
|
?.value
|
||||||
?.returnValue as? ResultValue.Error
|
?.returnValue as? ResultValue.Error
|
||||||
val hasErrorDiagnostics = result.reports.any {
|
val hasErrorDiagnostics = reports.any {
|
||||||
it.severity == ScriptDiagnostic.Severity.ERROR || it.severity == ScriptDiagnostic.Severity.FATAL
|
it.severity == ScriptDiagnostic.Severity.ERROR || it.severity == ScriptDiagnostic.Severity.FATAL
|
||||||
}
|
}
|
||||||
val diagnostics = result.reports
|
val diagnostics = reports
|
||||||
.filter { it.severity > ScriptDiagnostic.Severity.DEBUG }
|
.filter { it.severity > ScriptDiagnostic.Severity.DEBUG }
|
||||||
.joinToString("\n") {
|
.joinToString("\n") {
|
||||||
val ex = it.exception?.let { e -> ": ${e::class.simpleName}: ${e.message}" } ?: ""
|
val ex = it.exception?.let { e -> ": ${e::class.simpleName}: ${e.message}" } ?: ""
|
||||||
@@ -343,7 +380,8 @@ fun evalAndCapture(
|
|||||||
}.trim()
|
}.trim()
|
||||||
|
|
||||||
ScriptExecutionResult(
|
ScriptExecutionResult(
|
||||||
ok = result is ResultWithDiagnostics.Success &&
|
ok = compilationResult is ResultWithDiagnostics.Success &&
|
||||||
|
evaluationResult is ResultWithDiagnostics.Success &&
|
||||||
!hasErrorDiagnostics &&
|
!hasErrorDiagnostics &&
|
||||||
returnValueError == null &&
|
returnValueError == null &&
|
||||||
(!enforceRequiredParams || missingRequired.isEmpty()),
|
(!enforceRequiredParams || missingRequired.isEmpty()),
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ package work.slhaf.hub
|
|||||||
import kotlin.script.experimental.annotations.KotlinScript
|
import kotlin.script.experimental.annotations.KotlinScript
|
||||||
|
|
||||||
@KotlinScript(fileExtension = "hub.kts")
|
@KotlinScript(fileExtension = "hub.kts")
|
||||||
abstract class SimpleScript
|
abstract class SimpleScript(
|
||||||
|
val hostArgs: Array<String> = emptyArray(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -263,6 +263,34 @@ class WebHostApiTest {
|
|||||||
assertTrue(runtimeErrorBody.contains("boom (required=false"))
|
assertTrue(runtimeErrorBody.contains("boom (required=false"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun defaultParamValueIsInjectedIntoHostArgs() = withApp { _ ->
|
||||||
|
val create = client.post("/scripts/defaults") {
|
||||||
|
bearerRoot()
|
||||||
|
setBody(
|
||||||
|
"""
|
||||||
|
// @desc: default args test
|
||||||
|
// @param: name | required=false | default=world | desc=Name fallback
|
||||||
|
val args: Array<String> = emptyArray()
|
||||||
|
val kv = args.mapNotNull {
|
||||||
|
val i = it.indexOf('=')
|
||||||
|
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
|
||||||
|
}.toMap()
|
||||||
|
println("name=" + (kv["name"] ?: "missing"))
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatusCode.Created, create.status)
|
||||||
|
|
||||||
|
val runWithoutArg = client.get("/run/defaults") { bearerRoot() }
|
||||||
|
assertEquals(HttpStatusCode.OK, runWithoutArg.status)
|
||||||
|
assertTrue(runWithoutArg.bodyAsText().contains("name=world"))
|
||||||
|
|
||||||
|
val runWithArg = client.get("/run/defaults?name=alice") { bearerRoot() }
|
||||||
|
assertEquals(HttpStatusCode.OK, runWithArg.status)
|
||||||
|
assertTrue(runWithArg.bodyAsText().contains("name=alice"))
|
||||||
|
}
|
||||||
|
|
||||||
private fun withApp(testBlock: suspend io.ktor.server.testing.ApplicationTestBuilder.(java.nio.file.Path) -> Unit) {
|
private fun withApp(testBlock: suspend io.ktor.server.testing.ApplicationTestBuilder.(java.nio.file.Path) -> Unit) {
|
||||||
val scriptsDir = createTempDirectory("webhost-api-test-")
|
val scriptsDir = createTempDirectory("webhost-api-test-")
|
||||||
tempDirs.add(scriptsDir)
|
tempDirs.add(scriptsDir)
|
||||||
|
|||||||
Reference in New Issue
Block a user