Skip to content

Commit 04110b7

Browse files
Fix duplicate content-type header bug, add test (#12376)
This PR is similar to #12350, but targets the 4.10.x branch insead of 5.0.x. Micronaut adds a second `Content-Type` header to an HttpResponse when the result of the controller function is a `HttpResponse<Flux<String>>` (or other nested data type), and the controller explicitly sets the content type on the `HttpResponse`. This can be demonstrated with the following simple controller: ```kotlin @controller class VariableContentTypeController { @get("/") fun index(): HttpResponse<Flux<String>> { return HttpResponse.ok(Flux.just("Hello World!")).contentType("text/plain") } } ``` See https://github.com/wfhartford-wordly/micronaut-duplicate-content-type-headers for a full project demonstrating the bug. Calling this function from curl shows that two content type headers are present in the response. ``` $ curl localhost:8080 -v * Host localhost:8080 was resolved. * IPv6: ::1 * IPv4: 127.0.0.1 * Trying [::1]:8080... * Connected to localhost (::1) port 8080 > GET / HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/8.7.1 > Accept: */* > * Request completely sent off < HTTP/1.1 200 OK < Content-Type: text/plain < Content-Type: text/plain < date: Thu, 15 Jan 2026 23:45:04 GMT < transfer-encoding: chunked < * Connection #0 to host localhost left intact Hello World! ``` This PR fixes the bug by changing the `RouteExecutor` to call the `contentType` function which _sets_ the header rather than the `header` function which _adds_ it. I've also included a unit test which verifies the fix. I first encountered this bug in Micronaut version 4.3.8 and have confirmed that it is present in version 4.10.7 and on the 5.0.x branch.
1 parent 5079647 commit 04110b7

2 files changed

Lines changed: 61 additions & 1 deletion

File tree

http-server/src/main/java/io/micronaut/http/server/RouteExecutor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,7 @@ private Mono<MutableHttpResponse<?>> processPublisherBody(PropagatedContext prop
744744
).contextWrite(cv -> ReactorPropagation.addPropagatedContext(cv, propagatedContext).put(ServerRequestContext.KEY, request));
745745

746746
return Mono.<MutableHttpResponse<?>>just(response
747-
.header(HttpHeaders.CONTENT_TYPE, mediaType)
747+
.contentType(mediaType)
748748
.body(ReactivePropagation.propagate(propagatedContext, bodyPublisher)))
749749
.contextWrite(context -> ReactorPropagation.addPropagatedContext(context, propagatedContext).put(ServerRequestContext.KEY, request));
750750
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package io.micronaut.http.server
2+
3+
import io.micronaut.context.BeanContext
4+
import io.micronaut.core.propagation.PropagatedContext
5+
import io.micronaut.http.HttpHeaders
6+
import io.micronaut.http.HttpRequest
7+
import io.micronaut.http.HttpResponse
8+
import io.micronaut.http.MediaType
9+
import io.micronaut.http.MutableHttpResponse
10+
import io.micronaut.http.server.exceptions.response.ErrorResponseProcessor
11+
import io.micronaut.scheduling.executor.ExecutorSelector
12+
import io.micronaut.web.router.RouteInfo
13+
import io.micronaut.web.router.Router
14+
import io.micronaut.http.server.binding.RequestArgumentSatisfier
15+
import reactor.core.publisher.Flux
16+
import spock.lang.Specification
17+
18+
class RouteExecutorSpec extends Specification {
19+
20+
void "test duplicate content type header in processPublisherBody"() {
21+
given:
22+
Router router = Mock(Router)
23+
BeanContext beanContext = Mock(BeanContext)
24+
RequestArgumentSatisfier requestArgumentSatisfier = Mock(RequestArgumentSatisfier)
25+
HttpServerConfiguration serverConfiguration = new HttpServerConfiguration()
26+
ErrorResponseProcessor errorResponseProcessor = Mock(ErrorResponseProcessor)
27+
ExecutorSelector executorSelector = Mock(ExecutorSelector)
28+
29+
RouteExecutor routeExecutor = new RouteExecutor(
30+
router,
31+
beanContext,
32+
requestArgumentSatisfier,
33+
serverConfiguration,
34+
errorResponseProcessor,
35+
executorSelector
36+
)
37+
38+
HttpRequest<?> request = Mock(HttpRequest)
39+
MutableHttpResponse<?> response = HttpResponse.ok().contentType(MediaType.APPLICATION_JSON_TYPE)
40+
RouteInfo<?> routeInfo = Mock(RouteInfo)
41+
routeInfo.isReactive() >> true
42+
43+
// A publisher that is NOT a single publisher to trigger the bug
44+
Flux<String> bodyPublisher = Flux.just("item1", "item2")
45+
46+
when:
47+
// Use Groovy's private method access
48+
MutableHttpResponse<?> result = routeExecutor.processPublisherBody(
49+
PropagatedContext.getOrEmpty(),
50+
request,
51+
response,
52+
false, // isSinglePublisher = false
53+
bodyPublisher,
54+
routeInfo
55+
).block()
56+
57+
then:
58+
result.getHeaders().getAll(HttpHeaders.CONTENT_TYPE) == ["application/json"]
59+
}
60+
}

0 commit comments

Comments
 (0)