ktor 客户端 post 请求导致错误 `lateinit 属性 nextElementName 尚未初始化`

ktor client post request causes error `lateinit property nextElementName has not been initialized`

我正在尝试使用 ktor 向远程 API 发出简单的 post 请求。只有一个端点,请求和响应正文在 JSON.

我正在使用

我需要 POST 到达的终点是 https://mods.factorio.com/api/v2/mods/releases/init_upload - details here.

我已经创建了一个客户端

private val client = HttpClient(CIO) {
  install(Resources)
  install(Logging) {
    logger = Logger.DEFAULT
    level = LogLevel.HEADERS
  }
  install(ContentNegotiation) {
    json(Json {
      prettyPrint = true
      isLenient = true
    })
  }
  defaultRequest {
    header(HttpHeaders.Authorization, "Bearer 123")
  }
  followRedirects = false
  expectSuccess = true
}

我用它来发出 post 请求

  val response = client.post(portalUploadEndpoint) {
    contentType(ContentType.Application.Json)
    setBody(InitUploadRequest("123"))
  }

请求和响应主体是 JSON 个对象,我正在使用 Kotlinx 序列化。 (请参阅此问题底部的完整代码。)

但是我得到一个错误。我做错了什么吗?

Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property nextElementName has not been initialized
    at io.ktor.resources.serialization.ParametersEncoder.encodeValue(ParametersEncoder.kt:26)
    at kotlinx.serialization.encoding.AbstractEncoder.encodeString(AbstractEncoder.kt:51)
    at kotlinx.serialization.internal.StringSerializer.serialize(Primitives.kt:141)
    at kotlinx.serialization.internal.StringSerializer.serialize(Primitives.kt:138)
    at kotlinx.serialization.encoding.Encoder$DefaultImpls.encodeSerializableValue(Encoding.kt:282)
    at kotlinx.serialization.encoding.AbstractEncoder.encodeSerializableValue(AbstractEncoder.kt:18)
    at io.ktor.resources.serialization.ResourcesFormat.encodeToParameters(ResourcesFormat.kt:90)
    at io.ktor.resources.UrlBuilderKt.href(UrlBuilder.kt:49)
    at MainKt$main.invokeSuspend(main.kt:163)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
    at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
    at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
    at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
    at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)
    at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$default(Builders.kt:38)
    at kotlinx.coroutines.BuildersKt.runBlocking$default(Unknown Source)
    at MainKt.main(main.kt:47)
    at MainKt.main(main.kt)

完整代码

import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.DEFAULT
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.resources.Resources
import io.ktor.client.plugins.resources.post
import io.ktor.client.request.header
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.URLBuilder
import io.ktor.http.contentType
import io.ktor.http.takeFrom
import io.ktor.resources.Resource
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject


private val client = HttpClient(CIO) {
  install(Resources)
  install(Logging) {
    logger = Logger.DEFAULT
    level = LogLevel.HEADERS
  }
  install(ContentNegotiation) {
    json(Json {
      prettyPrint = true
      isLenient = true
    })
  }
  defaultRequest {
    header(HttpHeaders.Authorization, "Bearer 123")
  }
  followRedirects = false
  expectSuccess = true
}

fun main() = runBlocking {

  val apiBase = "https://mods.factorio.com/api/v2"
  val uploadEndpoint = "mods/releases/"

  val portalUploadEndpoint = URLBuilder(apiBase).apply {
    takeFrom(uploadEndpoint)
  }.buildString()

  println(portalUploadEndpoint)

  val response = client.post(portalUploadEndpoint) {
    contentType(ContentType.Application.Json)
    setBody(InitUploadRequest("123"))
  }

  println(response)
}

@Resource("/init_upload")
@Serializable
data class InitUploadRequest(
  @SerialName("mod") val modName: String,
)


@Serializable(with = InitUploadResponse.Serializer::class)
sealed interface InitUploadResponse {

  /**
   * @param[uploadUrl] URL the mod zip file should be uploaded to
   */
  @Serializable
  data class Success(
    @SerialName("upload_url") val uploadUrl: String,
  ) : InitUploadResponse

  object Serializer :
    JsonContentPolymorphicSerializer<InitUploadResponse>(InitUploadResponse::class) {
    override fun selectDeserializer(element: JsonElement) = when {
      "upload_url" in element.jsonObject -> Success.serializer()
      else                               -> Failure.serializer()
    }
  }
}

@Serializable
data class Failure(
  val error: String? = null,
  val message: String? = null,
) : InitUploadResponse

另见

我也在 YouTrack 上报告了这个 https://youtrack.jetbrains.com/issue/KTOR-4342/

问题是您将 String (URL) 传递给需要 ResourceHttpClient.post 扩展函数。您需要在那里传递 resource 或使用 io.ktor.client.request 包中的 HttpClient.post 扩展函数。

感谢 @aleksei-tirman 指出 Ktor 中有两个扩展函数导致了这种混淆。

这些可能会发生冲突 - 所以请确保使用正确的。

我还解决了另一个问题。我本来是说

The request and response body are JSON objects

请求实际上是表单参数。

而不是

  val response = client.post(portalUploadEndpoint) {
    contentType(ContentType.Application.Json)
    setBody(InitUploadRequest("123"))
  }

我改成了

  val response = client.submitForm(
      url = portalUploadEndpoint,
      formParameters = Parameters.build {
        append("mod", modName)
      }
    )

响应是一个JSON正文,我手动解码

    val initUploadResponse: InitUploadResponse =
      Json.decodeFromString(InitUploadResponse.serializer(), response.bodyAsText())

然后我从 HttpClient 中删除了 Resources Ktor 插件 - 它不需要。