关于 connekt-script-writer
面向使用 Connekt 的 Kotlin HTTP 客户端用户的脚本编写技能,指导创建由执行器调度请求的 `.connekt.kts` 脚本。内容涵盖环境配置、请求声明、响应处理和多步场景组织。
主要功能
- 01编写 Connekt Kotlin DSL 请求脚本
- 02从 connekt.env.json 读取配置与密钥
- 03配置 HTTP 方法、请求体和认证
- 04解析响应并编写断言
- 05通过 useCase 组织多请求场景
- 06导入脚本并声明 Maven 依赖
适用场景
- 01编写使用环境配置的 API 请求脚本
- 02测试请求响应并提取 JSON 数据
- 03组织创建资源后再验证的多步流程
- 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是 OkHttpResponseval result by GET(...) then { expr }会捕获then块的返回值decode<T>(jsonPath)从响应正文反序列化 JSONuseCase("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 验证——仅用于本地开发。