Compare commits
3 Commits
ae94615095
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a3d2ff1cb8 | |||
| f332159217 | |||
| bcf0a316a6 |
@@ -1,12 +1,6 @@
|
||||
services:
|
||||
slhaf-hub:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
BUILD_IMAGE: ${BUILD_IMAGE:-gradle:9.0.0-jdk17}
|
||||
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-eclipse-temurin:17-jre}
|
||||
image: slhaf-hub:latest
|
||||
image: ${SLHAF_HUB_IMAGE:-docker.io/slhafzjw/slhaf-hub:latest}
|
||||
container_name: slhaf-hub
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// @timeout: 10s
|
||||
// @param: name | default=world | desc=hello <name> | required=false
|
||||
// @param: upper | default=false | desc=upper text | required=false
|
||||
// @response: json
|
||||
import java.time.LocalDateTime
|
||||
|
||||
lateinit var args: Array<String>
|
||||
|
||||
@@ -159,6 +159,54 @@ fun removeCachedMetadata(scriptFile: File) {
|
||||
compiledScriptCache.remove(scriptFile.canonicalPath)
|
||||
}
|
||||
|
||||
fun validateCompilationAndCapture(scriptFile: File): ScriptExecutionResult {
|
||||
synchronized(evalLock) {
|
||||
val oldOut = System.out
|
||||
val oldErr = System.err
|
||||
val buffer = ByteArrayOutputStream()
|
||||
val ps = PrintStream(buffer, true, Charsets.UTF_8.name())
|
||||
|
||||
return try {
|
||||
System.setOut(ps)
|
||||
System.setErr(ps)
|
||||
|
||||
val original = scriptFile.readText()
|
||||
val metadata = metadataForFile(scriptFile, original)
|
||||
val injected = injectArgsBridgeDeclaration(original)
|
||||
val compilationResult = compiledScriptFor(scriptFile, injected)
|
||||
val reports = compilationResult.reports
|
||||
val hasErrorDiagnostics = reports.any {
|
||||
it.severity == ScriptDiagnostic.Severity.ERROR || it.severity == ScriptDiagnostic.Severity.FATAL
|
||||
}
|
||||
val diagnostics = reports
|
||||
.filter { it.severity > ScriptDiagnostic.Severity.DEBUG }
|
||||
.joinToString("\n") {
|
||||
val ex = it.exception?.let { e -> ": ${e::class.simpleName}: ${e.message}" } ?: ""
|
||||
"[${it.severity}] ${it.message}$ex"
|
||||
}
|
||||
|
||||
val output = buffer.toString(Charsets.UTF_8.name()).trim()
|
||||
val finalText = buildString {
|
||||
if (output.isNotEmpty()) appendLine(output)
|
||||
if (diagnostics.isNotEmpty()) appendLine(diagnostics)
|
||||
}.trim()
|
||||
|
||||
ScriptExecutionResult(
|
||||
ok = compilationResult is ResultWithDiagnostics.Success && !hasErrorDiagnostics,
|
||||
output = finalText,
|
||||
metadata = metadata,
|
||||
missingRequiredParams = emptyList(),
|
||||
timedOut = false,
|
||||
)
|
||||
} finally {
|
||||
ps.flush()
|
||||
ps.close()
|
||||
System.setOut(oldOut)
|
||||
System.setErr(oldErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): ScriptExecutionResult {
|
||||
return evalAndCapture(scriptFile, requestContext, enforceRequiredParams = true)
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata
|
||||
private const val DEFAULT_SCRIPT_TIMEOUT_MS = 10_000L
|
||||
private val metadataParamNameRegex = Regex("[A-Za-z0-9._-]+")
|
||||
private val supportedResponseValues = setOf("text", "json", "html")
|
||||
|
||||
internal fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}"
|
||||
|
||||
private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
|
||||
var description: String? = null
|
||||
var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS
|
||||
var responseType = ScriptResponseType.TEXT
|
||||
val params = mutableListOf<ScriptParamDefinition>()
|
||||
|
||||
scriptContent.lines().forEach { raw ->
|
||||
@@ -28,6 +30,15 @@ private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
|
||||
parseTimeoutMs(raw)?.let { timeoutMs = it }
|
||||
return@forEach
|
||||
}
|
||||
if (comment.startsWith("@response:", ignoreCase = true)) {
|
||||
val raw = comment.substringAfter(":").trim().lowercase()
|
||||
responseType = when (raw) {
|
||||
"json" -> ScriptResponseType.JSON
|
||||
"html" -> ScriptResponseType.HTML
|
||||
else -> ScriptResponseType.TEXT
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
if (comment.startsWith("@param:", ignoreCase = true)) {
|
||||
val payload = comment.substringAfter(":").trim()
|
||||
if (payload.isBlank()) return@forEach
|
||||
@@ -61,7 +72,12 @@ private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
return ScriptMetadata(description = description, params = params, timeoutMs = timeoutMs)
|
||||
return ScriptMetadata(
|
||||
description = description,
|
||||
params = params,
|
||||
timeoutMs = timeoutMs,
|
||||
responseType = responseType,
|
||||
)
|
||||
}
|
||||
|
||||
fun validateScriptMetadata(scriptContent: String): List<String> {
|
||||
@@ -81,6 +97,13 @@ fun validateScriptMetadata(scriptContent: String): List<String> {
|
||||
}
|
||||
return@forEachIndexed
|
||||
}
|
||||
if (comment.startsWith("@response:", ignoreCase = true)) {
|
||||
val rawResponse = comment.substringAfter(":").trim().lowercase()
|
||||
if (rawResponse !in supportedResponseValues) {
|
||||
errors += "line $lineNo: invalid @response '$rawResponse'. expected one of: text, json, html."
|
||||
}
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
if (comment.startsWith("@param:", ignoreCase = true)) {
|
||||
val payload = comment.substringAfter(":").trim()
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package work.slhaf.hub
|
||||
|
||||
enum class ScriptResponseType {
|
||||
TEXT,
|
||||
JSON,
|
||||
HTML,
|
||||
}
|
||||
|
||||
data class ScriptParamDefinition(
|
||||
val name: String,
|
||||
val required: Boolean = false,
|
||||
@@ -11,6 +17,7 @@ data class ScriptMetadata(
|
||||
val description: String? = null,
|
||||
val params: List<ScriptParamDefinition> = emptyList(),
|
||||
val timeoutMs: Long = 10_000L,
|
||||
val responseType: ScriptResponseType = ScriptResponseType.TEXT,
|
||||
)
|
||||
|
||||
data class ScriptRequestContext(
|
||||
|
||||
@@ -59,6 +59,7 @@ private fun metadataValidationMessage(errors: List<String>): String =
|
||||
appendLine("examples:")
|
||||
appendLine("// @desc: Demo greeting API")
|
||||
appendLine("// @timeout: 10s")
|
||||
appendLine("// @response: text")
|
||||
appendLine("// @param: name | required=false | default=world | desc=Name to greet")
|
||||
}.trim()
|
||||
|
||||
@@ -92,14 +93,22 @@ private fun runFailureMessage(result: ScriptExecutionResult): String {
|
||||
|
||||
fun metadataJson(scriptName: String, metadata: ScriptMetadata, source: String): String {
|
||||
val description = metadata.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
|
||||
val responseType = metadata.responseType.name.lowercase()
|
||||
val params = metadata.params.joinToString(",") { param ->
|
||||
val defaultValue = param.defaultValue?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
|
||||
val desc = param.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
|
||||
"""{"name":"${param.name.jsonEscaped()}","required":${param.required},"defaultValue":$defaultValue,"description":$desc}"""
|
||||
}
|
||||
return """{"script":"${scriptName.jsonEscaped()}","source":"${source.jsonEscaped()}","description":$description,"timeoutMs":${metadata.timeoutMs},"params":[$params]}"""
|
||||
return """{"script":"${scriptName.jsonEscaped()}","source":"${source.jsonEscaped()}","description":$description,"timeoutMs":${metadata.timeoutMs},"responseType":"$responseType","params":[$params]}"""
|
||||
}
|
||||
|
||||
private fun contentTypeFor(metadata: ScriptMetadata): ContentType =
|
||||
when (metadata.responseType) {
|
||||
ScriptResponseType.TEXT -> ContentType.Text.Plain
|
||||
ScriptResponseType.JSON -> ContentType.Application.Json
|
||||
ScriptResponseType.HTML -> ContentType.Text.Html
|
||||
}
|
||||
|
||||
fun loadMetadata(script: File): Pair<ScriptMetadata, String> {
|
||||
val cached = cachedMetadata(script)
|
||||
if (cached != null) return cached to "cache"
|
||||
@@ -133,7 +142,7 @@ suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) {
|
||||
script.writeText(content)
|
||||
removeCachedMetadata(script)
|
||||
|
||||
val result = evalAndCapture(script, ScriptRequestContext(), enforceRequiredParams = false)
|
||||
val result = validateCompilationAndCapture(script)
|
||||
if (!result.ok) {
|
||||
script.delete()
|
||||
removeCachedMetadata(script)
|
||||
@@ -197,7 +206,7 @@ suspend fun handleUpdateScript(call: ApplicationCall, scriptsDir: File) {
|
||||
script.writeText(newContent)
|
||||
removeCachedMetadata(script)
|
||||
|
||||
val result = evalAndCapture(script, ScriptRequestContext(), enforceRequiredParams = false)
|
||||
val result = validateCompilationAndCapture(script)
|
||||
if (!result.ok) {
|
||||
script.writeText(previousContent)
|
||||
removeCachedMetadata(script)
|
||||
@@ -255,7 +264,7 @@ suspend fun handleRunRequest(call: ApplicationCall, scriptsDir: File, consumeBod
|
||||
runFailureMessage(result)
|
||||
},
|
||||
status = status,
|
||||
contentType = ContentType.Text.Plain
|
||||
contentType = if (result.ok) contentTypeFor(metadata) else ContentType.Text.Plain
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ class WebAuthAndScriptApiTest : WebHostTestSupport() {
|
||||
val metaText = meta.bodyAsText()
|
||||
assertTrue(metaText.contains("\"script\":\"demo\""))
|
||||
assertTrue(metaText.contains("\"timeoutMs\":10000"))
|
||||
assertTrue(metaText.contains("\"responseType\":\"text\""))
|
||||
|
||||
val run = client.get("/run/demo?name=Alice") { bearerRoot() }
|
||||
assertEquals(HttpStatusCode.OK, run.status)
|
||||
@@ -103,4 +104,49 @@ class WebAuthAndScriptApiTest : WebHostTestSupport() {
|
||||
assertTrue(body.contains("metadata validation failed"))
|
||||
assertTrue(body.contains("missing required option"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metadataRejectsUnsupportedResponseType() = withApp { _ ->
|
||||
val create = client.post("/scripts/bad-response") {
|
||||
bearerRoot()
|
||||
setBody(
|
||||
"""
|
||||
// @desc: bad response
|
||||
// @response: xml
|
||||
// @param: name | required=false | default=world | desc=name
|
||||
val args: Array<String> = emptyArray()
|
||||
println("ok")
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
assertEquals(HttpStatusCode.BadRequest, create.status)
|
||||
val body = create.bodyAsText()
|
||||
assertTrue(body.contains("metadata validation failed"))
|
||||
assertTrue(body.contains("invalid @response"))
|
||||
assertTrue(body.contains("text, json, html"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createWithRequiredParamsValidatesByCompilationOnly() = withApp { _ ->
|
||||
val create = client.post("/scripts/required-param-script") {
|
||||
bearerRoot()
|
||||
setBody(
|
||||
"""
|
||||
// @desc: required param demo
|
||||
// @param: owner | required=true | desc=Repository owner
|
||||
lateinit var args: Array<String>
|
||||
val owner = args.mapNotNull {
|
||||
val i = it.indexOf('=')
|
||||
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
|
||||
}.toMap()["owner"] ?: error("missing required param: owner")
|
||||
println(owner)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
assertEquals(HttpStatusCode.Created, create.status)
|
||||
|
||||
val runMissing = client.get("/run/required-param-script") { bearerRoot() }
|
||||
assertEquals(HttpStatusCode.BadRequest, runMissing.status)
|
||||
assertTrue(runMissing.bodyAsText().contains("missing required params: owner"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.HttpHeaders
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
@@ -120,4 +121,43 @@ class WebRunApiTest : WebHostTestSupport() {
|
||||
assertEquals(HttpStatusCode.OK, run.status)
|
||||
assertTrue(run.bodyAsText().contains("name=world"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runSuccessContentTypeCanBeJsonOrHtml() = withApp { _ ->
|
||||
val createJson = client.post("/scripts/json-out") {
|
||||
bearerRoot()
|
||||
setBody(
|
||||
"""
|
||||
// @desc: json output
|
||||
// @response: json
|
||||
val args: Array<String> = emptyArray()
|
||||
println("{\"ok\":true}")
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
assertEquals(HttpStatusCode.Created, createJson.status)
|
||||
|
||||
val runJson = client.get("/run/json-out") { bearerRoot() }
|
||||
assertEquals(HttpStatusCode.OK, runJson.status)
|
||||
assertTrue((runJson.headers[HttpHeaders.ContentType] ?: "").startsWith("application/json"))
|
||||
assertTrue(runJson.bodyAsText().contains("\"ok\":true"))
|
||||
|
||||
val createHtml = client.post("/scripts/html-out") {
|
||||
bearerRoot()
|
||||
setBody(
|
||||
"""
|
||||
// @desc: html output
|
||||
// @response: html
|
||||
val args: Array<String> = emptyArray()
|
||||
println("<h1>ok</h1>")
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
assertEquals(HttpStatusCode.Created, createHtml.status)
|
||||
|
||||
val runHtml = client.get("/run/html-out") { bearerRoot() }
|
||||
assertEquals(HttpStatusCode.OK, runHtml.status)
|
||||
assertTrue((runHtml.headers[HttpHeaders.ContentType] ?: "").startsWith("text/html"))
|
||||
assertTrue(runHtml.bodyAsText().contains("<h1>ok</h1>"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ Global options:
|
||||
|
||||
Commands:
|
||||
health
|
||||
template <script>
|
||||
type
|
||||
list
|
||||
show <script>
|
||||
@@ -52,6 +53,7 @@ Commands:
|
||||
sub-delete <name>
|
||||
|
||||
Examples:
|
||||
kotlin slhaf-hub-cli.kts template hello
|
||||
kotlin slhaf-hub-cli.kts --token-file=./scripts/.host-api-token type
|
||||
kotlin slhaf-hub-cli.kts --token-file=./scripts/.host-api-token sub-list
|
||||
kotlin slhaf-hub-cli.kts --token-file=./scripts/.host-api-token sub-create demo --scripts=hello,time
|
||||
@@ -159,6 +161,23 @@ fun parseScriptsArg(args: List<String>): Set<String> {
|
||||
return items
|
||||
}
|
||||
|
||||
fun initialScriptTemplate(name: String): String =
|
||||
"""
|
||||
// @desc: $name
|
||||
// @timeout: 10s
|
||||
// @response: text
|
||||
// @param: sample | required=false | default=value | desc=example parameter
|
||||
|
||||
lateinit var args: Array<String>
|
||||
val kv = args.mapNotNull {
|
||||
val i = it.indexOf('=')
|
||||
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
|
||||
}.toMap()
|
||||
|
||||
println("script=$name")
|
||||
println("sample=" + (kv["sample"] ?: "value"))
|
||||
""".trimIndent()
|
||||
|
||||
fun request(
|
||||
client: HttpClient,
|
||||
baseUrl: String,
|
||||
@@ -198,6 +217,10 @@ fun main(args: Array<String>) {
|
||||
val (status, body) =
|
||||
when (input.command) {
|
||||
"health" -> request(client, base, null, "GET", "/health")
|
||||
"template" -> {
|
||||
val script = requireScriptName(input.commandArgs)
|
||||
200 to initialScriptTemplate(script)
|
||||
}
|
||||
"type" -> request(client, base, token, "GET", "/type")
|
||||
"list" -> request(client, base, token, "GET", "/scripts")
|
||||
"show" -> {
|
||||
|
||||
@@ -476,6 +476,7 @@ fun initialScriptTemplate(name: String): String =
|
||||
"""
|
||||
// @desc: $name
|
||||
// @timeout: 10s
|
||||
// @response: text
|
||||
// @param: sample | required=false | default=value | desc=example parameter
|
||||
|
||||
lateinit var args: Array<String>
|
||||
|
||||
Reference in New Issue
Block a user