feat(web): support @response metadata (text/json/html) and return matching run content type

This commit is contained in:
2026-02-25 19:30:17 +08:00
parent f332159217
commit a3d2ff1cb8
8 changed files with 129 additions and 3 deletions

View File

@@ -2,6 +2,7 @@
// @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=false // @param: upper | default=false | desc=upper text | required=false
// @response: json
import java.time.LocalDateTime import java.time.LocalDateTime
lateinit var args: Array<String> lateinit var args: Array<String>

View File

@@ -6,12 +6,14 @@ import java.util.concurrent.ConcurrentHashMap
private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata
private const val DEFAULT_SCRIPT_TIMEOUT_MS = 10_000L private const val DEFAULT_SCRIPT_TIMEOUT_MS = 10_000L
private val metadataParamNameRegex = Regex("[A-Za-z0-9._-]+") 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()}" internal fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}"
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
var responseType = ScriptResponseType.TEXT
val params = mutableListOf<ScriptParamDefinition>() val params = mutableListOf<ScriptParamDefinition>()
scriptContent.lines().forEach { raw -> scriptContent.lines().forEach { raw ->
@@ -28,6 +30,15 @@ private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
parseTimeoutMs(raw)?.let { timeoutMs = it } parseTimeoutMs(raw)?.let { timeoutMs = it }
return@forEach 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)) { if (comment.startsWith("@param:", ignoreCase = true)) {
val payload = comment.substringAfter(":").trim() val payload = comment.substringAfter(":").trim()
if (payload.isBlank()) return@forEach 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> { fun validateScriptMetadata(scriptContent: String): List<String> {
@@ -81,6 +97,13 @@ fun validateScriptMetadata(scriptContent: String): List<String> {
} }
return@forEachIndexed 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)) { if (comment.startsWith("@param:", ignoreCase = true)) {
val payload = comment.substringAfter(":").trim() val payload = comment.substringAfter(":").trim()

View File

@@ -1,5 +1,11 @@
package work.slhaf.hub package work.slhaf.hub
enum class ScriptResponseType {
TEXT,
JSON,
HTML,
}
data class ScriptParamDefinition( data class ScriptParamDefinition(
val name: String, val name: String,
val required: Boolean = false, val required: Boolean = false,
@@ -11,6 +17,7 @@ data class ScriptMetadata(
val description: String? = null, val description: String? = null,
val params: List<ScriptParamDefinition> = emptyList(), val params: List<ScriptParamDefinition> = emptyList(),
val timeoutMs: Long = 10_000L, val timeoutMs: Long = 10_000L,
val responseType: ScriptResponseType = ScriptResponseType.TEXT,
) )
data class ScriptRequestContext( data class ScriptRequestContext(

View File

@@ -59,6 +59,7 @@ private fun metadataValidationMessage(errors: List<String>): String =
appendLine("examples:") appendLine("examples:")
appendLine("// @desc: Demo greeting API") appendLine("// @desc: Demo greeting API")
appendLine("// @timeout: 10s") appendLine("// @timeout: 10s")
appendLine("// @response: text")
appendLine("// @param: name | required=false | default=world | desc=Name to greet") appendLine("// @param: name | required=false | default=world | desc=Name to greet")
}.trim() }.trim()
@@ -92,12 +93,20 @@ private fun runFailureMessage(result: ScriptExecutionResult): String {
fun metadataJson(scriptName: String, metadata: ScriptMetadata, source: String): String { fun metadataJson(scriptName: String, metadata: ScriptMetadata, source: String): String {
val description = metadata.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null" val description = metadata.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
val responseType = metadata.responseType.name.lowercase()
val params = metadata.params.joinToString(",") { param -> val params = metadata.params.joinToString(",") { param ->
val defaultValue = param.defaultValue?.let { "\"${it.jsonEscaped()}\"" } ?: "null" val defaultValue = param.defaultValue?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
val desc = param.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null" val desc = param.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
"""{"name":"${param.name.jsonEscaped()}","required":${param.required},"defaultValue":$defaultValue,"description":$desc}""" """{"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> { fun loadMetadata(script: File): Pair<ScriptMetadata, String> {
@@ -255,7 +264,7 @@ suspend fun handleRunRequest(call: ApplicationCall, scriptsDir: File, consumeBod
runFailureMessage(result) runFailureMessage(result)
}, },
status = status, status = status,
contentType = ContentType.Text.Plain contentType = if (result.ok) contentTypeFor(metadata) else ContentType.Text.Plain
) )
} }

View File

@@ -57,6 +57,7 @@ class WebAuthAndScriptApiTest : WebHostTestSupport() {
val metaText = meta.bodyAsText() val metaText = meta.bodyAsText()
assertTrue(metaText.contains("\"script\":\"demo\"")) assertTrue(metaText.contains("\"script\":\"demo\""))
assertTrue(metaText.contains("\"timeoutMs\":10000")) assertTrue(metaText.contains("\"timeoutMs\":10000"))
assertTrue(metaText.contains("\"responseType\":\"text\""))
val run = client.get("/run/demo?name=Alice") { bearerRoot() } val run = client.get("/run/demo?name=Alice") { bearerRoot() }
assertEquals(HttpStatusCode.OK, run.status) assertEquals(HttpStatusCode.OK, run.status)
@@ -104,6 +105,27 @@ class WebAuthAndScriptApiTest : WebHostTestSupport() {
assertTrue(body.contains("missing required option")) 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 @Test
fun createWithRequiredParamsValidatesByCompilationOnly() = withApp { _ -> fun createWithRequiredParamsValidatesByCompilationOnly() = withApp { _ ->
val create = client.post("/scripts/required-param-script") { val create = client.post("/scripts/required-param-script") {

View File

@@ -5,6 +5,7 @@ import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.HttpHeaders
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -120,4 +121,43 @@ class WebRunApiTest : WebHostTestSupport() {
assertEquals(HttpStatusCode.OK, run.status) assertEquals(HttpStatusCode.OK, run.status)
assertTrue(run.bodyAsText().contains("name=world")) 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>"))
}
} }

View File

@@ -36,6 +36,7 @@ Global options:
Commands: Commands:
health health
template <script>
type type
list list
show <script> show <script>
@@ -52,6 +53,7 @@ Commands:
sub-delete <name> sub-delete <name>
Examples: 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 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-list
kotlin slhaf-hub-cli.kts --token-file=./scripts/.host-api-token sub-create demo --scripts=hello,time 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 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( fun request(
client: HttpClient, client: HttpClient,
baseUrl: String, baseUrl: String,
@@ -198,6 +217,10 @@ fun main(args: Array<String>) {
val (status, body) = val (status, body) =
when (input.command) { when (input.command) {
"health" -> request(client, base, null, "GET", "/health") "health" -> request(client, base, null, "GET", "/health")
"template" -> {
val script = requireScriptName(input.commandArgs)
200 to initialScriptTemplate(script)
}
"type" -> request(client, base, token, "GET", "/type") "type" -> request(client, base, token, "GET", "/type")
"list" -> request(client, base, token, "GET", "/scripts") "list" -> request(client, base, token, "GET", "/scripts")
"show" -> { "show" -> {

View File

@@ -476,6 +476,7 @@ fun initialScriptTemplate(name: String): String =
""" """
// @desc: $name // @desc: $name
// @timeout: 10s // @timeout: 10s
// @response: text
// @param: sample | required=false | default=value | desc=example parameter // @param: sample | required=false | default=value | desc=example parameter
lateinit var args: Array<String> lateinit var args: Array<String>