springspockrest-assuredspring-testspring-restdocs

Duplicate Content-Type header in Spring REST Docs using RestAssuredMockMvc


I'm currently writing tests using RestAssuredMockMvc together with Spring REST Docs.

Setup

In my global spec setup, I configure the headers like this:

package com.animore.config

import com.fasterxml.jackson.databind.ObjectMapper
import io.restassured.http.ContentType
import io.restassured.module.mockmvc.RestAssuredMockMvc
import io.restassured.module.mockmvc.specification.MockMvcRequestSpecification
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs
import org.springframework.context.annotation.Import
import org.springframework.http.HttpHeaders
import org.springframework.restdocs.RestDocumentationContextProvider
import org.springframework.restdocs.RestDocumentationExtension
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext
import spock.lang.Specification

import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris
import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint

@Import([TestWebConfig, TestSecurityConfig])
@AutoConfigureRestDocs
@ExtendWith([RestDocumentationExtension.class])
abstract class ControllerTest extends Specification {

    @Autowired
    WebApplicationContext context

    @Autowired
    RestDocumentationContextProvider restDocumentation

    protected MockMvcRequestSpecification spec

    @Autowired
    ObjectMapper objectMapper

    def setup() {
        spec = RestAssuredMockMvc
                .given()
                .headers(
                        HttpHeaders.AUTHORIZATION, "Bearer accessToken",
                        HttpHeaders.ACCEPT, ContentType.JSON,
                        HttpHeaders.CONTENT_TYPE, ContentType.JSON
                )
                .mockMvc(
                    MockMvcBuilders.webAppContextSetup(context)
                    .apply(
                        MockMvcRestDocumentation.documentationConfiguration(restDocumentation)
                        .operationPreprocessors()
                        .withRequestDefaults(
                                modifyUris()
                                .scheme("https")
                                .host("docs.api.com")
                                .removePort(),
                                prettyPrint()
                        )
                        .withResponseDefaults(prettyPrint())
                    )
                    .build())
                .log().all()
    }
}

Then, in an actual test case, I call:

package com.animore.user.presentation

import com.animore.common.web.message.SuccessMessage
import com.animore.config.ControllerTest
import com.animore.config.JwtTokenProvider
import com.animore.user.application.usecase.UserUseCase
import io.restassured.module.mockmvc.RestAssuredMockMvc
import org.spockframework.spring.SpringBean
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation

import static org.hamcrest.Matchers.equalTo
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields

@WebMvcTest(UserController.class)
class UserControllerSpec extends ControllerTest {

    @SpringBean
    private JwtTokenProvider jwtTokenProvider = Mock()

    @SpringBean
    private UserUseCase userUseCase = Mock()

    def "사용자 로그아웃 restassured version"() {
        expect:
        RestAssuredMockMvc.given()
        .spec(spec)
            .when()
                .patch("/api/user/logout")
            .then()
                .statusCode(200)
                .body("code", equalTo(200))
                .body("message", equalTo(SuccessMessage.SUCCESS_MSG))
                .body("data", equalTo(null))
                .apply(
                    MockMvcRestDocumentation.document(
                        "user/logout",
//                        preprocessRequest(modifyHeaders().set("Content-Type", "application/json")),
                        responseFields(
                            fieldWithPath("code").description("응답 코드"),
                            fieldWithPath("message").description("응답 메시지"),
                            fieldWithPath("data").description("응답 데이터")
                        )
                    )
                )
    }

}

Problem

In the generated http-request.adoc, I see two Content-Type headers:

Content-Type: application/json
Content-Type: application/json

Interestingly, if I uncomment the modifyHeaders().set(...) line, the duplicate goes away and only one remains.

I debugged this and found that inside Spring’s MockHttpServletRequest, the Content-Type header is already set twice before the documentation handler even touches it. So the problem occurs earlier — either in RestAssured or Spring MockMvc setup.

What I Investigated

Spring’s MockHttpServletRequest#addHeader(...) method has logic like:

public void addHeader(String name, Object value) {
    if (HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(name) &&
            !this.headers.containsKey(HttpHeaders.CONTENT_TYPE)) {
        setContentType(value.toString());
    }
    else if (HttpHeaders.ACCEPT_LANGUAGE.equalsIgnoreCase(name) &&
            !this.headers.containsKey(HttpHeaders.ACCEPT_LANGUAGE)) {
        try {
            HttpHeaders headers = new HttpHeaders();
            headers.add(HttpHeaders.ACCEPT_LANGUAGE, value.toString());
            List<Locale> locales = headers.getAcceptLanguageAsLocales();
            this.locales.clear();
            this.locales.addAll(locales);
            if (this.locales.isEmpty()) {
                this.locales.add(Locale.ENGLISH);
            }
        }
        catch (IllegalArgumentException ex) {
            // Invalid Accept-Language format -> just store plain header
        }
        doAddHeaderValue(name, value, true);
    }
    else {
        doAddHeaderValue(name, value, false);
    }
}

Which means: Even if Content-Type is already set, a duplicate may be added later unless filtered manually.

I understand this might be intentional — addHeader() is designed to allow multiple headers and be neutral — not prevent duplication. Still, when combined with RestAssured and Spring REST Docs, it creates an awkward result in http-request.adoc.

Dependencies I'm Using

// Spring REST Docs
testImplementation("org.springframework.restdocs:spring-restdocs-restassured:3.0.3")
testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc:3.0.3")

// RestAssured + Spring MockMvc
testImplementation("io.rest-assured:spring-mock-mvc:5.5.1") {
    exclude(group = "org.springframework", module = "spring-webmvc")
}
testImplementation("org.springframework:spring-webmvc:6.2.6")
testImplementation("io.rest-assured:rest-assured:5.5.1") {
    exclude(group = "org.apache.johnzon", module = "johnzon-mapper")
    exclude(group = "org.codehaus.jackson", module = "jackson-mapper-asl")
    exclude(group = "commons-io", module = "commons-io")
}
testImplementation("commons-io:commons-io:2.19.0")

// Spock
testImplementation("org.spockframework:spock-core:2.4-M6-groovy-4.0")
testImplementation("org.spockframework:spock-spring:2.4-M6-groovy-4.0")

What I'm Asking

Thanks in advance for any insights. 🙏


Solution

  • I debugged through the Spring code and also ended up finding method MockHttpServletRequest::addHeader suspicious. IMO, it contains a bug in case of Content-Type headers. Therefore, I created Spring issue #34913.

    Update: The bug has been fixed and will probably be released with Spring 6.2.8.