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

Viewed 230

I'm trying to use ktor to make a simple post requests to a remote API. There's a single endpoint, and the request and response body is in JSON.

I'm using

  • ktor 2.0.1
  • Kotlin 1.6.21
  • Kotlinx Serialization 1.3.2
  • Kotlin Coroutines 1.6.1

The endpoint I need to POST to is https://mods.factorio.com/api/v2/mods/releases/init_upload - details here.

I've created a client

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
}

and I use it to make a post request

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

The request and response body are JSON objects, and I'm using Kotlinx Serialization. (See the full code at the bottom of this question.)

However I get an error. Am I doing something wrong?

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$1.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)

full code

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

See also

I've also report this on YouTrack https://youtrack.jetbrains.com/issue/KTOR-4342/

2 Answers

The problem is that you're passing a String (URL) to the HttpClient.post extension function that expects a Resource. You need to pass a resource there or use the HttpClient.post extension function from the io.ktor.client.request package.

Thanks to @aleksei-tirman for pointing out that there are two extension functions in Ktor that cause this confusion.

These can clash - so make sure you use the correct one.

There was an additional problem that I've also resolved. I originally said

The request and response body are JSON objects

The requests are actually form parameters.

Instead of

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

I changed it to

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

The response is a JSON body, which I manually decode

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

I then removed the Resources Ktor plugin from the HttpClient - it's not needed.

Related