Compare commits
7 Commits
d79ff57b89
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a3d2ff1cb8 | |||
| f332159217 | |||
| bcf0a316a6 | |||
| ae94615095 | |||
| c0bb645125 | |||
| 923479b4cf | |||
| e3886ff2ed |
@@ -14,10 +14,10 @@ RUN gradle --no-daemon clean installDist
|
|||||||
FROM ${RUNTIME_IMAGE}
|
FROM ${RUNTIME_IMAGE}
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=build /workspace/build/install/kotlin-scripts-host /app/kotlin-scripts-host
|
COPY --from=build /workspace/build/install/slhaf-hub /app/slhaf-hub
|
||||||
RUN mkdir -p /app/scripts
|
RUN mkdir -p /app/scripts
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["/app/kotlin-scripts-host/bin/kotlin-scripts-host"]
|
ENTRYPOINT ["/app/slhaf-hub/bin/slhaf-hub"]
|
||||||
CMD ["--host=0.0.0.0", "--port=8080", "--scripts-dir=/app/scripts"]
|
CMD ["--host=0.0.0.0", "--port=8080", "--scripts-dir=/app/scripts"]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Language:
|
|||||||
- Dynamic script loading from `scripts/*.hub.kts` without restarting host
|
- Dynamic script loading from `scripts/*.hub.kts` without restarting host
|
||||||
- Root/Sub token authorization model
|
- Root/Sub token authorization model
|
||||||
- Metadata in script comments (`@desc`, `@timeout`, `@param`)
|
- Metadata in script comments (`@desc`, `@timeout`, `@param`)
|
||||||
|
- Compiled script cache (reuses compiled artifacts when script file is unchanged)
|
||||||
- Script CRUD + run + metadata APIs
|
- Script CRUD + run + metadata APIs
|
||||||
- Subtoken management APIs
|
- Subtoken management APIs
|
||||||
- Run concurrency limit (`--max-run-concurrency`)
|
- Run concurrency limit (`--max-run-concurrency`)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
- 从 `scripts/*.hub.kts` 动态加载脚本,无需重启 host
|
- 从 `scripts/*.hub.kts` 动态加载脚本,无需重启 host
|
||||||
- Root/Sub token 鉴权模型
|
- Root/Sub token 鉴权模型
|
||||||
- 脚本注释 metadata(`@desc`、`@timeout`、`@param`)
|
- 脚本注释 metadata(`@desc`、`@timeout`、`@param`)
|
||||||
|
- 编译缓存(脚本文件未变化时复用已编译产物)
|
||||||
- 脚本 CRUD + run + meta API
|
- 脚本 CRUD + run + meta API
|
||||||
- subtoken 管理 API
|
- subtoken 管理 API
|
||||||
- 运行并发限制(`--max-run-concurrency`)
|
- 运行并发限制(`--max-run-concurrency`)
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ plugins {
|
|||||||
|
|
||||||
val kotlinVersion = "2.2.20"
|
val kotlinVersion = "2.2.20"
|
||||||
|
|
||||||
repositories {
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(kotlin("stdlib"))
|
implementation(kotlin("stdlib"))
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
slhaf-hub:
|
slhaf-hub:
|
||||||
build:
|
image: ${SLHAF_HUB_IMAGE:-docker.io/slhafzjw/slhaf-hub:latest}
|
||||||
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
|
|
||||||
container_name: slhaf-hub
|
container_name: slhaf-hub
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1 +1,18 @@
|
|||||||
rootProject.name = "slhaf-hub"
|
rootProject.name = "slhaf-hub"
|
||||||
|
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
maven("https://maven.aliyun.com/repository/gradle-plugin")
|
||||||
|
maven("https://maven.aliyun.com/repository/public")
|
||||||
|
gradlePluginPortal()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
|
||||||
|
repositories {
|
||||||
|
maven("https://maven.aliyun.com/repository/public")
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -159,6 +159,54 @@ fun removeCachedMetadata(scriptFile: File) {
|
|||||||
compiledScriptCache.remove(scriptFile.canonicalPath)
|
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 {
|
fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): ScriptExecutionResult {
|
||||||
return evalAndCapture(scriptFile, requestContext, enforceRequiredParams = true)
|
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 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()
|
||||||
@@ -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(
|
||||||
@@ -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> {
|
||||||
@@ -133,7 +142,7 @@ suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) {
|
|||||||
script.writeText(content)
|
script.writeText(content)
|
||||||
removeCachedMetadata(script)
|
removeCachedMetadata(script)
|
||||||
|
|
||||||
val result = evalAndCapture(script, ScriptRequestContext(), enforceRequiredParams = false)
|
val result = validateCompilationAndCapture(script)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
script.delete()
|
script.delete()
|
||||||
removeCachedMetadata(script)
|
removeCachedMetadata(script)
|
||||||
@@ -197,7 +206,7 @@ suspend fun handleUpdateScript(call: ApplicationCall, scriptsDir: File) {
|
|||||||
script.writeText(newContent)
|
script.writeText(newContent)
|
||||||
removeCachedMetadata(script)
|
removeCachedMetadata(script)
|
||||||
|
|
||||||
val result = evalAndCapture(script, ScriptRequestContext(), enforceRequiredParams = false)
|
val result = validateCompilationAndCapture(script)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
script.writeText(previousContent)
|
script.writeText(previousContent)
|
||||||
removeCachedMetadata(script)
|
removeCachedMetadata(script)
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -103,4 +104,49 @@ class WebAuthAndScriptApiTest : WebHostTestSupport() {
|
|||||||
assertTrue(body.contains("metadata validation failed"))
|
assertTrue(body.contains("metadata validation failed"))
|
||||||
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
|
||||||
|
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.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>"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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" -> {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user