mcpField
C
connekt-script-writer

connekt-script-writer

github.com/amplicode开发134343 次安装7 周前更新
代码仓库

关于 connekt-script-writer

面向使用 Connekt 的 Kotlin HTTP 客户端用户的脚本编写技能,指导创建由执行器调度请求的 `.connekt.kts` 脚本。内容涵盖环境配置、请求声明、响应处理和多步场景组织。

主要功能

  1. 01编写 Connekt Kotlin DSL 请求脚本
  2. 02从 connekt.env.json 读取配置与密钥
  3. 03配置 HTTP 方法、请求体和认证
  4. 04解析响应并编写断言
  5. 05通过 useCase 组织多请求场景
  6. 06导入脚本并声明 Maven 依赖

适用场景

  1. 01编写使用环境配置的 API 请求脚本
  2. 02测试请求响应并提取 JSON 数据
  3. 03组织创建资源后再验证的多步流程
  4. 04复用认证脚本处理 OAuth2 请求

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/amplicode/spring-skills --skill connekt-script-writer

安装后在 Claude Code 中输入/connekt-script-writer确认已加载。

详细介绍

Connekt 脚本编写器

Connekt 是由 Kotlin 脚本驱动的 HTTP 客户端。脚本使用 .connekt.kts 扩展名,且可在顶层直接使用完整的 Connekt DSL——无需样板代码,也无需 main 函数,只需声明和请求。

生成脚本时,始终从 connekt.env.json 读取基础 URL 和机密,使用 val x: String by env,不要硬编码值。保存脚本时使用 .connekt.kts 扩展名。

执行模型

脚本只注册请求——由运行器决定执行哪个请求。 脚本不会像常规程序那样从上到下运行。这意味着:

  • 请求之间不能有命令式代码。 不要使用 println、if/else,也不要在 then 或 useCase 块之外赋值。
  • 断言只能放在 then { } 或 useCase { } 块内——不能放在请求之间的顶层。
  • 不要将其他请求的结果直接插入 URL。 改用 pathParam,这样运行器就能在执行时解析值。
  • 顶层允许的内容: val x by env、data class、configureClient、val x by oauth(...)、扩展函数(例如,为共享标头扩展 RequestBuilder)、请求声明(val x by GET/POST/...)和 useCase 块。
// ✅ CORRECT — pass result via pathParam
val petId by POST("$host/api/pets") {
    contentType("application/json")
    body("""{"name": "Fido"}""")
} then {
    decode<Long>("$.id")
}

GET("$host/api/pets/{petId}") {
    pathParam("petId", petId)
} then {
    assert(code == 200)
}

// ❌ WRONG — never interpolate request results in URL
GET("$host/api/pets/$petId") then {
    assert(code == 200)
}

RequestBuilder 上的扩展函数是应用共享配置的好方法:

fun RequestBuilder.withApiKey() {
    header("X-Api-Key", apiKey)
    header("X-Request-Id", java.util.UUID.randomUUID().toString())
}

GET("$host/api/pets") {
    withApiKey()
}

脚本结构

.connekt.kts 文件遵循以下常规顺序:

// 1. File-level annotations (imports, dependencies)
@file:Import("shared_auth.connekt.kts")
@file:DependsOn("com.example:my-lib:1.0")

// 2. Third-party imports (if needed)
import org.assertj.core.api.Assertions.assertThat

// 3. Environment variables from connekt.env.json
val host: String by env
val apiKey: String by env

// 4. Data classes for typed deserialization
data class Pet(val id: Long, val name: String, val status: String)

// 5. Global client configuration
configureClient {
    insecure()  // for local dev only
}

// 6. OAuth setup (if needed)
val auth by oauth(
    authorizeEndpoint = "...",
    clientId = "...",
    clientSecret = "...",
    scope = "openid",
    tokenEndpoint = "...",
    redirectUri = "http://localhost:8080/callback"
)

// 7. HTTP requests
val pets by GET("$host/api/pets") then {
    decode<List<Pet>>("$.content")
}

// 8. Use cases for grouping related requests
val result by useCase("Create and verify") {
    val created by POST("$host/api/pets") {
        contentType("application/json")
        body("""{"name": "Fido"}""")
    } then {
        decode<Pet>()
    }
    created
}

关键约定:

  • val x by env 从 connekt.env.json 读取值——属性名就是查找键
  • val response by GET(...) 委托执行并绑定结果
  • then { ... } 链接响应处理——在块内,this 是 OkHttp Response
  • val result by GET(...) then { expr } 会捕获 then 块的返回值
  • decode<T>(jsonPath) 从响应正文反序列化 JSON
  • useCase("name") { ... } 将请求分组;最后一个表达式就是返回值
  • assertSoftly { assert(...) } 会收集所有断言失败,再统一报告
  • 请求之间不能有代码——断言和逻辑只能放在 then 或 useCase 中
  • 通过 pathParam 传递请求结果,绝不要将结果插入 URL 字符串

DSL 参考

HTTP 方法
GET("$host/api/resource")
POST("$host/api/resource")
PUT("$host/api/resource")
PATCH("$host/api/resource")
DELETE("$host/api/resource")
HEAD("$host/api/resource")
OPTIONS("$host/api/resource")
TRACE("$host/api/resource")

每种方法都接受可选的 name 参数和配置 lambda:

GET("$host/api/pets", name = "List all pets") {
    header("Accept", "application/json")
    queryParam("status", "available")
}
请求配置

标头:

GET("$host/api/resource") {
    header("X-Api-Key", apiKey)
    headers("Accept" to "application/json", "X-Request-Id" to "abc123")
    contentType("application/json")
    accept("application/json")
}

查询参数:

GET("$host/api/pets") {
    queryParam("status", "available")
    queryParam("limit", 20)
    queryParams("sort" to "name", "order" to "asc")
}

路径参数(在 URL 中使用 {name} 占位符):

DELETE("$host/api/owners/{ownerId}/pets/{petId}") {
    pathParam("ownerId", 1)
    pathParam("petId", 5)
}

请求选项:

GET("$host/old-url") {
    noRedirect()   // don't follow 3xx redirects
    noCookies()    // exclude cookies from this request
    http2()        // use HTTP/2 (h2c)
}
请求正文

JSON 正文:

POST("$host/api/pets") {
    contentType("application/json")
    body("""{"name": "Fido", "species": "dog"}""")
}

表单数据(自动设置 Content-Type):

POST("$host/api/login") {
    formData {
        field("username", "alice")
        field("password", "secret")
    }
}

多部分:

POST("$host/api/upload") {
    multipart {
        part(name = "metadata", contentType = "application/json") {
            body("""{"description": "profile picture"}""")
        }
        file(name = "photo", fileName = "avatar.jpg", file = java.io.File("/tmp/avatar.jpg"))
    }
}

字节数组:

POST("$host/api/upload") {
    header("Content-Type", "application/octet-stream")
    body(java.io.File("/tmp/data.bin").readBytes())
}
身份验证

Basic 和 Bearer:

GET("$host/api/resource") { basicAuth("user", "pass") }
GET("$host/api/resource") { bearerAuth(myToken) }

OAuth2(授权码流程):

val auth by oauth(
    authorizeEndpoint = "$authHost/oauth/authorize",
    clientId = clientId,
    clientSecret = clientSecret,
    scope = "openid profile",
    tokenEndpoint = "$authHost/oauth/token",
    redirectUri = "http://localhost:8080/callback"
)

GET("$host/api/protected") {
    bearerAuth(auth.accessToken)
}

OAuth2 会自动打开浏览器供用户登录,并启动本地回调服务。auth 对象提供 accessToken 和 refreshToken。

Keycloak 简写:

val auth by oauth(
    KeycloakOAuthParameters(
        serverBaseUrl = keycloakHost,
        realm = "my-realm",
        protocol = "openid-connect",
        clientId = "my-client",
        clientSecret = "secret",
        scope = "openid",
        callbackPort = 8080,
        callbackPath = "/callback"
    )
)
响应处理

在 then 块内,this 是 OkHttp Response——你可以访问 code、body、header("Name") 等。

验证和提取:

GET("$host/api/pets") then {
    assert(code == 200) { "Expected 200 but got $code" }
    val text = body!!.string()
    println(text)
}

使用 decode<T>() 进行类型化反序列化:

val pets by GET("$host/api/pets") then {
    decode<List<Pet>>("$.content")  // JSONPath extraction
}

val pet by GET("$host/api/pet/1") then {
    decode<Pet>()  // root object (default when no JSONPath given)
}

原始 JSONPath 访问:

GET("$host/api/stats") then {
    val ctx = jsonPath()
    val count = ctx.decode<Int>("$.totalCount")
    val names = ctx.decode<List<String>>("$.items[*].name")
}

链接请求——通过 pathParam 将一个请求的结果传给下一个请求:

val petId by POST("$host/api/pets") {
    contentType("application/json")
    body("""{"name": "Fido"}""")
} then {
    decode<Long>("$.id")
}

GET("$host/api/pets/{petId}") {
    pathParam("petId", petId)
} then {
    assert(code == 200)
}
用例
何时使用 useCase
  • 只有当场景包含多个请求,且需要在请求之间传递结果时(例如,创建资源后再检查其是否存在),才使用 useCase。
  • 单个 HTTP 请求(即使带有 then 块)也绝不封装在 useCase 中。
  • 如果用户描述的场景看起来像一个业务用例(包含多个步骤),技能必须询问:“这是一个包含多个步骤的业务场景——要用 useCase 封装吗?”——并在生成代码前等待答复。

单个 HTTP 请求——独立使用 then,绝不封装在 useCase 中:

val createdPet by POST("$host/api/pets") {
    contentType("application/json")
    body("""{"name": "Rex"}""")
} then {
    decode<Pet>()
}

使用 useCase 将多个相关请求组合在一起。最后一个表达式是返回值。

val payment by useCase("Place order and pay") {
    val order by POST("$host/api/orders") {
        contentType("application/json")
        body("""{"productId": 42, "quantity": 1}""")
    } then {
        decode<Order>()
    }

    val payment by POST("$host/api/payments") {
        contentType("application/json")
        body("""{"orderId": ${order.id}, "amount": ${order.totalPrice}}""")
    } then {
        decode<Payment>()
    }

    payment
}

匿名用例(无名称)工作方式相同:

useCase {
    GET("$host/foo") then { assert(code == 200) }
    GET("$host/bar") then { assert(code == 200) }
}
环境变量

使用属性委托从 connekt.env.json 读取。属性名称就是查找键:

val host: String by env
val port: Int by env

connekt.env.json 文件:

{
  "env": {
    "host": "http://localhost:8080",
    "port": 8080
  }
}

支持的类型:String、Int、Long、Double、Boolean。缺少键时会抛出错误。

断言

Kotlin assert(使用 --kotlin-power-assert 时附带 Power Assert 诊断信息):

GET("$host/api/pets") then {
    assert(code == 200) { "Expected 200 but got $code" }
}

AssertJ(需要导入,已弃用,请勿在新脚本中使用):

import org.assertj.core.api.Assertions.assertThat

GET("$host/api/pets") then {
    assertThat(code).isEqualTo(200)
    val pets = decode<List<Pet>>("$.content")
    assertThat(pets).isNotEmpty
}

软断言——报告前收集所有失败:

GET("$host/api/users/1") then {
    val user = decode<User>()
    assertSoftly {
        assert(user.name == "Alice")
        assert(user.email.contains("@"))
        assert(user.age > 0)
        assert(user.active)
    }
}
脚本导入与依赖

导入另一个脚本(路径相对于导入脚本):

@file:Import("shared_auth.connekt.kts")
@file:Import("utils.connekt.kts")

导入的顶层声明(值、函数、类)均可使用。传递导入也有效。

外部 Maven 依赖:

@file:DependsOn("com.example:my-lib:1.0.0")
SSL/TLS 与客户端配置
// Global client config
configureClient {
    insecure()  // disable SSL verification (dev only!)
    addX509Certificate(java.io.File("certs/my-ca.crt"))
    addKeyStore(java.io.File("certs/truststore.jks"), "changeit")
    readTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
}

// Per-request override
GET("$host/api/slow-endpoint") {
    configureClient {
        readTimeout(120, java.util.concurrent.TimeUnit.SECONDS)
    }
}

常见模式

带断言的简单 GET
val host: String by env

GET("$host/api/health") then {
    assert(code == 200) { "Health check failed with status $code" }
    println(body!!.string())
}
带 JSON 请求体和响应提取的 POST
val host: String by env
data class Pet(val id: Long, val name: String, val status: String)

val created by POST("$host/api/pets") {
    contentType("application/json")
    body("""{"name": "Fido", "status": "available"}""")
} then {
    assert(code == 201) { "Expected 201 but got $code" }
    decode<Pet>()
}
CRUD 工作流
val host: String by env
data class Pet(val id: Long, val name: String, val status: String)

// Многошаговый бизнес-сценарий — оправданное применение useCase
val result by useCase("Pet CRUD") {
    val pet by POST("$host/api/pets") {
        contentType("application/json")
        body("""{"name": "Fido", "status": "available"}""")
    } then {
        assert(code == 201)
        decode<Pet>()
    }

    GET("$host/api/pets/{petId}") {
        pathParam("petId", pet.id)
    } then {
        assert(code == 200)
        assert(decode<Pet>().name == "Fido")
    }

    val updated by PUT("$host/api/pets/{petId}") {
        pathParam("petId", pet.id)
        contentType("application/json")
        body("""{"name": "Rex", "status": "available"}""")
    } then {
        assert(code == 200)
        decode<Pet>()
    }
    assert(updated.name == "Rex")

    DELETE("$host/api/pets/{petId}") {
        pathParam("petId", pet.id)
    } then { assert(code == 204) }

    GET("$host/api/pets/{petId}") {
        pathParam("petId", pet.id)
    } then { assert(code == 404) }

    pet
}
OAuth2 身份验证请求
val host: String by env
val authHost: String by env
val clientId: String by env
val clientSecret: String by env

val auth by oauth(
    authorizeEndpoint = "$authHost/realms/my-realm/protocol/openid-connect/auth",
    clientId = clientId,
    clientSecret = clientSecret,
    scope = "openid",
    tokenEndpoint = "$authHost/realms/my-realm/protocol/openid-connect/token",
    redirectUri = "http://localhost:8080/callback"
)

val owners by GET("$host/api/owners") {
    bearerAuth(auth.accessToken)
    queryParam("lastNameContains", "smith")
} then {
    assert(code == 200)
    decode<List<String>>("$.content[*].name")
}
表单登录后调用 API
val host: String by env

POST("$host/login") {
    formData {
        field("username", "admin")
        field("password", "admin")
    }
} then {
    assert(code == 200 || code == 302) { "Login failed" }
}

// Session cookie is sent automatically
val dashboard by GET("$host/api/dashboard") then {
    assert(code == 200)
    decode<Map<String, Any>>()
}
文件上传(multipart)
val host: String by env

val uploadResult by POST("$host/api/files/upload") {
    multipart {
        part(name = "metadata", contentType = "application/json") {
            body("""{"description": "Monthly report", "category": "reports"}""")
        }
        file(name = "document", fileName = "report.pdf", file = java.io.File("/tmp/report.pdf"))
    }
} then {
    assert(code == 201) { "Upload failed with status $code" }
    decode<Map<String, Any>>()
}
分页循环
val host: String by env

data class Event(val id: String, val summary: String)
data class EventsPage(val items: List<Event>, val nextPageToken: String?)

val allEvents by useCase("Fetch all events") {
    val events = mutableListOf<Event>()
    var nextPageToken: String? = null

    do {
        nextPageToken = GET("$host/api/events") {
            bearerAuth("my-token")
            queryParam("maxResults", 50)
            if (nextPageToken != null) {
                queryParam("pageToken", nextPageToken!!)
            }
        } then {
            val page = decode<EventsPage>()
            events.addAll(page.items)
            page.nextPageToken
        }
    } while (nextPageToken != null)

    events.toList()
}
使用 @file:Import 复用多个脚本

auth_setup.connekt.kts(共享):

val host: String by env
val clientId: String by env
val clientSecret: String by env

val auth by oauth(
    authorizeEndpoint = "$host/oauth/authorize",
    clientId = clientId,
    clientSecret = clientSecret,
    scope = "openid",
    tokenEndpoint = "$host/oauth/token",
    redirectUri = "http://localhost:8080/callback"
)

api_tests.connekt.kts(导入上述脚本):

@file:Import("auth_setup.connekt.kts")

GET("$host/api/protected") {
    bearerAuth(auth.accessToken)
} then {
    assert(code == 200)
}
使用软断言验证响应
val host: String by env
data class User(val name: String, val email: String, val age: Int, val active: Boolean)

GET("$host/api/users/1") then {
    assert(code == 200) { "Expected 200 but got $code" }
    val user = decode<User>()

    assertSoftly {
        assert(user.name == "Alice")
        assert(user.email.contains("@"))
        assert(user.age > 0)
        assert(user.active)
    }
}

重要说明

  • 默认生成单个请求——不使用 useCase。 只有当场景明确包含多个步骤,且需要在请求之间传递数据时,才适合使用 useCase。单个请求(无论是否带有 then)绝不封装在 useCase 中。这条规则优先级最高。
  • 脚本注册请求——并非以命令式方式执行请求。 顶层请求之间不得有代码。断言和逻辑只能放在 then 或 useCase 块中。
  • 绝不要将请求结果插入 URL("$host/api/pets/$petId")——应始终在 URL 中使用 {petId} 占位符,并通过 pathParam("petId", petId) 传入参数。
  • RequestBuilder 的顶层扩展函数可用于复用请求配置(共享标头、身份验证等)。
  • 绝不要使用已弃用的函数:vars、variable<T>()、doRead()、readString()、readInt()、readLong()、readBoolean()。所有响应提取均使用 decode<T>()。
  • Cookie 会在会话 Cookie jar 中自动管理——除非使用 noCookies(),否则无需手动处理 Cookie。
  • 默认情况下,OkHttp 会跟随 3xx 重定向。使用 noRedirect() 检查重定向响应。
  • insecure() 会禁用所有 SSL 验证——仅用于本地开发。

评论

支持 Markdown