diff --git a/scripts/hello.hub.kts b/scripts/hello.hub.kts index 59a01bb..c1b35c3 100644 --- a/scripts/hello.hub.kts +++ b/scripts/hello.hub.kts @@ -2,6 +2,7 @@ // @timeout: 10s // @param: name | default=world | desc=hello | required=false // @param: upper | default=false | desc=upper text | required=false +// @response: json import java.time.LocalDateTime lateinit var args: Array diff --git a/src/main/kotlin/work/slhaf/hub/script/ScriptMetadataEngine.kt b/src/main/kotlin/work/slhaf/hub/script/ScriptMetadataEngine.kt index 19c56d8..c878fa5 100644 --- a/src/main/kotlin/work/slhaf/hub/script/ScriptMetadataEngine.kt +++ b/src/main/kotlin/work/slhaf/hub/script/ScriptMetadataEngine.kt @@ -6,12 +6,14 @@ import java.util.concurrent.ConcurrentHashMap private val metadataCache = ConcurrentHashMap>() // 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() 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 { @@ -81,6 +97,13 @@ fun validateScriptMetadata(scriptContent: String): List { } 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() diff --git a/src/main/kotlin/work/slhaf/hub/script/ScriptRuntime.kt b/src/main/kotlin/work/slhaf/hub/script/ScriptRuntime.kt index bc171f6..5810717 100644 --- a/src/main/kotlin/work/slhaf/hub/script/ScriptRuntime.kt +++ b/src/main/kotlin/work/slhaf/hub/script/ScriptRuntime.kt @@ -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 = emptyList(), val timeoutMs: Long = 10_000L, + val responseType: ScriptResponseType = ScriptResponseType.TEXT, ) data class ScriptRequestContext( diff --git a/src/main/kotlin/work/slhaf/hub/web/WebScriptService.kt b/src/main/kotlin/work/slhaf/hub/web/WebScriptService.kt index ba77f8b..9454901 100644 --- a/src/main/kotlin/work/slhaf/hub/web/WebScriptService.kt +++ b/src/main/kotlin/work/slhaf/hub/web/WebScriptService.kt @@ -59,6 +59,7 @@ private fun metadataValidationMessage(errors: List): 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 { val cached = cachedMetadata(script) if (cached != null) return cached to "cache" @@ -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 ) } diff --git a/src/test/kotlin/work/slhaf/hub/web/WebAuthAndScriptApiTest.kt b/src/test/kotlin/work/slhaf/hub/web/WebAuthAndScriptApiTest.kt index c5e72ae..ddd7e6f 100644 --- a/src/test/kotlin/work/slhaf/hub/web/WebAuthAndScriptApiTest.kt +++ b/src/test/kotlin/work/slhaf/hub/web/WebAuthAndScriptApiTest.kt @@ -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) @@ -104,6 +105,27 @@ class WebAuthAndScriptApiTest : WebHostTestSupport() { 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 = 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") { diff --git a/src/test/kotlin/work/slhaf/hub/web/WebRunApiTest.kt b/src/test/kotlin/work/slhaf/hub/web/WebRunApiTest.kt index 654c526..87f094c 100644 --- a/src/test/kotlin/work/slhaf/hub/web/WebRunApiTest.kt +++ b/src/test/kotlin/work/slhaf/hub/web/WebRunApiTest.kt @@ -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 = 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 = emptyArray() + println("

ok

") + """.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("

ok

")) + } } diff --git a/tools/slhaf-hub-cli.kts b/tools/slhaf-hub-cli.kts index dc76251..69bf4a3 100755 --- a/tools/slhaf-hub-cli.kts +++ b/tools/slhaf-hub-cli.kts @@ -36,6 +36,7 @@ Global options: Commands: health + template