ktor icon indicating copy to clipboard operation
ktor copied to clipboard

Content-Type in a request headers

Open mister11 opened this issue 5 years ago • 18 comments

Ktor Version

1.1.4

Ktor Engine Used(client or server and name)

Apache

JVM Version, Operating System and Relevant Context

1.8, macOS Mojave and Linux Mint, IDEA 2019.1.2 CE

Feedback

I'm accessing and API that requires me to set Content-Type for all requests, but I'm getting io.ktor.http.UnsafeHeaderException: Header Content-Type is controlled by the engine and cannot be set explicitly

Current implementation:

val client = HttpClient(Apache) {
        install(JsonFeature) {
            serializer = GsonSerializer()
        }
        install(Logging) {
            level = LogLevel.HEADERS
        }
        defaultRequest {
            header("Content-Type", "application/vnd.api+json")
        }
    }
get("/test") {
            client.get<String>("url...") {
                headers {
                    // other headers
                }
            }
        }

mister11 avatar May 14 '19 14:05 mister11

As an additional information, I can solve this issue by using OkHttp with a network interceptor, but I'm curious how to do the same thing using Apache client

mister11 avatar May 14 '19 22:05 mister11

Hi @mister11, thanks for the report. We introduced acceptContentTypes in JsonFeature since 1.2.0. It provides you possibility to set custom Accept and Content-Type headers automatically.

Could you check and report if it solves your problem?

e5l avatar May 27 '19 08:05 e5l

Not sure if I'm doing something wrong, but this is not working:

val client = HttpClient(Apache) {
        install(JsonFeature) {
            serializer = GsonSerializer()
            acceptContentTypes = acceptContentTypes + listOf(ContentType.parse("application/vnd.api+json; ext=bulk"))
        }
    }

This just sets Accept header and I need Content-Type header for requests.

To make it more clear, here is a working version of OkHttp interceptor implementation:

fun provideHeadersInterceptor() = Interceptor { chain ->
    val requestBuilder = chain.request().newBuilder()
        .addHeader("Content-Type", "application/vnd.api+json")

    chain.proceed(requestBuilder.build())
}
fun provideOkHttpClient(): HttpClient = HttpClient(OkHttp) {
    engine {
        addNetworkInterceptor(provideHeadersInterceptor())
    }
}

mister11 avatar May 27 '19 15:05 mister11

any solutions? I am in the same situation, but for me even interceptior does not seem to work as I would expected and I'm getting

Exception in thread "main" java.lang.ClassCastException: class shared.model.ProductUpdateDto cannot be cast to class io.ktor.client.call.HttpClientCall (shared.model.ProductUpdateDto and io.ktor.client.call.HttpClientCall are in unnamed module of loader 'app')

stosik avatar Aug 01 '19 20:08 stosik

I can't use Ktor client just because I'm unable to set Content-Type. Even your examples doesn't work:

   val message = client.post<HelloWorld> {
      url("http://127.0.0.1:8080/")
      contentType(ContentType.Application.Json)
      body = HelloWorld(hello = "world")
   }

sannysoft avatar Jan 17 '20 09:01 sannysoft

@sannysoft , the same doesn't work for me neither. But I found this post - and I can admit, this workaround works. But... it's a workaround :( (https://github.com/ktorio/ktor/issues/635)

val response = client.call(url) {
   method = HttpMethod.Post
   body = TextContent(json.writeValueAsString(userData), contentType = ContentType.Application.Json)
}.response

Andromedids avatar Jan 29 '20 15:01 Andromedids

Ali-OSS also encountered this problem. We need the ability to manually control any headers including Content-Type.

wfxr avatar Mar 03 '20 09:03 wfxr

Why can't clients control Content-Type header? I'm not sure I understand why the engine "has" to.

It's also confusing since the public API of HttpMessageBuilder includes a setter

Agreed we need the ability to manually control this header for a number of reasons.

aajtodd avatar May 13 '20 14:05 aajtodd

any solutions guys?

dbof10 avatar May 27 '20 16:05 dbof10

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.

oleg-larshin avatar Aug 10 '20 15:08 oleg-larshin

any solutions guys?

loalexzzzz avatar Sep 22 '20 08:09 loalexzzzz

Ran into the same problem. Found out that I was use HttpMethod.Get and append("Content-Type", "application/json") at the same time. It is total fine for HttpMethod.Post request. Seems like this is the reason cause the problem. The error message is useless in this case.

Firerer avatar Apr 26 '21 16:04 Firerer

I'm also facing this issue. The api I'm calling REQUIRES Content-Type header in a GET request (yes I know why?), otherwise it returns error. I'm currently using another http library, but would like to move my library to Multiplatform and Ktor seems to be a good option. Is content-type blocked in Ktor GET for a reason? Are there any plans to allow it?

kalgecin avatar Jun 29 '21 10:06 kalgecin

bump on this. I'm facing the same issue, where the get requires the content-type be specified

minxylynx avatar Jan 13 '22 23:01 minxylynx

Also blocked by this. Isn't this a normal thing to do? Why isn't it allowed...

adamwaite avatar Feb 03 '22 08:02 adamwaite

Oh dear, I am blocked on my PUT request on KMM Android Project! Any resolution guys?

sud007 avatar Mar 25 '22 12:03 sud007

I've solved this problem by registering my custom content types using the ContentNegotiation plugin in a fairly simple way at least for binary content. I think the ktor documentation should contain an example like this since this is a fairly common use case.

example: https://github.com/wireapp/kalium/pull/324

typfel avatar Mar 25 '22 22:03 typfel

On YouTrack this is marked as fixed, but I was still unable to set Content-Type header to GET request using ktor 2.0.3. Eventually I came up with this relatively simple workaround:

class EmptyContentWithContentType(
    override val contentType: ContentType
) : OutgoingContent.NoContent() {

    override val contentLength: Long = 0

    override fun toString(): String = "EmptyContent(contentType='$contentType')"
}

And then in request builder:

setBody(EmptyContentWithContentType(ContentType("application", "definitely.not.json")))

Kosert avatar Jul 22 '22 06:07 Kosert

Still no solution?

flaringapp avatar Mar 17 '23 20:03 flaringapp

Yep no solution we need a content-type = application/x-www-form-urlencoded sadly, this has yet not worked for us!

sud007 avatar Mar 28 '23 13:03 sud007

@rsinukov could you check if we can do something?

e5l avatar Mar 28 '23 13:03 e5l

Having conducted some investigation, I realized that ktor erases the Content-Type header in ContentNegotiation plugin, namely in io.ktor.client.plugins.contentnegotiation.ContentNegotiation.convertRequest():

...
request.headers.remove(HttpHeaders.ContentType)
...

Nevertheless, this header is appended to the request when ktor request is being converted to the engine request. E.g., in OkHttp it's done using io.ktor.client.engine.mergeHeaders() method used in file io.ktor.client.engine.okhttp.OkHttpEngine.kt, method convertToOkHttpRequest(). The most interesting part is inside io.ktor.client.engine.mergeHeaders() method. Indeed, it tries to resolve content type and length, and append Content-Type header:

...
val type = content.contentType?.toString()
    ?: content.headers[HttpHeaders.ContentType]
    ?: requestHeaders[HttpHeaders.ContentType]

val length = content.contentLength?.toString()
    ?: content.headers[HttpHeaders.ContentLength]
    ?: requestHeaders[HttpHeaders.ContentLength]

type?.let { block(HttpHeaders.ContentType, it) }
length?.let { block(HttpHeaders.ContentLength, it) }

In the end, I believe Content-Type header shouldn't be erased in the first place, but at least it'll be nice to provide explanation stated directly in content negotiation plugin documentation.

flaringapp avatar Mar 28 '23 13:03 flaringapp

@sud007 Can you please elaborate on what doesn't work? This test passes:


    @Test
    fun testEmptyBodyWithContentTypeAndGet() = testSuspend {
        val client = HttpClient(MockEngine) {
            engine {
                addHandler { request ->
                    assertEquals("application/protobuf", request.headers[HttpHeaders.ContentType])
                    respond("OK")
                }
            }
        }

        client.get("/") {
            header(HttpHeaders.ContentType, ContentType.Application.ProtoBuf)
        }
    }

rsinukov avatar Mar 28 '23 14:03 rsinukov

@rsinukov Please try adding content negotiation plugin

install(ContentNegotiation) {
    json()
}

And sending post request with any @Serializable object:

client.post("/") {
    header(HttpHeaders.ContentType, ContentType.Application.Json)
    setBody(
        SomeJsonObject("Hello", "Ktor")
    )
}

The test you've provided will fail (assert block should be updated to application/json):

expected:<application/json> but was:<null>

flaringapp avatar Mar 29 '23 18:03 flaringapp

@flaringapp But isn't it only relevant for MockEngine, because it doesn't merge headers? In the real request Content-Type header will be present.

rsinukov avatar Mar 30 '23 11:03 rsinukov

real question is what is the reason of removing the header if it was set by the user?

kalgecin avatar Mar 30 '23 11:03 kalgecin

@rsinukov Yes, you are right. In the real request in will be present. I just don't want to say that the issue is explicitly with the MockEngine. Going back to content negotiation plugin which erases Content-Type header: it operates on HttpRequestPipeline.Transform phase, and the real request is being transformed and executed on HttpRequestPipeline.Send phase. Meaning, anywhere in between these two phases we won't be able to access the header no matter what engine we use.

flaringapp avatar Mar 30 '23 11:03 flaringapp

Another question is why to completely delegate header appending to an engine. Is it feasible to keep the header by default, and let any engine override/remove it if necessary?

flaringapp avatar Mar 30 '23 11:03 flaringapp

@flaringapp

Meaning, anywhere in between these two phases we won't be able to access the header no matter what engine we use.

You are able to access it through content.contentType, the same way as mergeHeaders does.

@kalgecin @flaringapp Can you elaborate on what problems it causes?

rsinukov avatar Mar 30 '23 14:03 rsinukov

@rsinukov in my case, a test with MockEngine that verifies Content-Type header was failing. As you stated.

flaringapp avatar Mar 30 '23 14:03 flaringapp