Spring Boot Version: 3.3.5. Response content type changed from application/json to application/xml after adding new dependency org.springframework.boot:spring-boot-starter-data-rest.

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-rest' // <-- a new dependecy has been added
    implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml'
}

Controller

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/test-user")
    public ResponseEntity<User> getUser() {
        User user = new User("test-user");
        return ResponseEntity
                .status(HttpStatus.OK)
                .body(user);
    }

    @GetMapping("/test-user-error")
    public User getUserException() {
        throw new MyCustomException("my-custom-error");
    }
}

Controller Advice

@RestControllerAdvice
public class AdviceController {

    @ExceptionHandler
    public ResponseEntity<ErrorsResponseDto> handleError(MyCustomException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(new ErrorsResponseDto(e.getMessage()));
    }
}

Behaviour with spring-boot-starter-data-rest dependency

curl -v http://127.0.0.1:8080/api/users/test-user

*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080
> GET /api/users/test-user HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/8.9.1
> Accept: */*
>
< HTTP/1.1 200
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Wed, 13 Nov 2024 15:47:38 GMT
<
{"name":"test-user"}* Connection #0 to host 127.0.0.1 left intact

logs

2024-11-13T18:47:38.285+03:00 DEBUG 14812 --- [nio-8080-exec-3] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.demo.UserController#getUser()
2024-11-13T18:47:38.302+03:00 DEBUG 14812 --- [nio-8080-exec-3] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Using 'application/json', given [*/*] and supported [application/json, application/*+json, application/xml;charset=UTF-8, text/xml;charset=UTF-8, application/*+xml;charset=UTF-8]
2024-11-13T18:47:38.304+03:00 DEBUG 14812 --- [nio-8080-exec-3] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Writing [User[name=test-user]]

Response content-type is application/json. This is OK

curl -v http://127.0.0.1:8080/api/users/test-user-error

> curl -v http://127.0.0.1:8080/api/users/test-user-error
*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080
> GET /api/users/test-user-error HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/8.9.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 400
< Content-Type: application/xml;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Wed, 13 Nov 2024 15:51:22 GMT
< Connection: close
<
<ErrorsResponseDto><error>my-custom-error</error></ErrorsResponseDto>* shutting down connection #0

logs

2024-11-13T18:51:22.447+03:00 DEBUG 14812 --- [nio-8080-exec-6] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.demo.UserController#getUserException()
2024-11-13T18:51:22.451+03:00 DEBUG 14812 --- [nio-8080-exec-6] .m.m.a.ExceptionHandlerExceptionResolver : Using @ExceptionHandler com.example.demo.AdviceController#handleError(MyCustomException)
2024-11-13T18:51:22.454+03:00 DEBUG 14812 --- [nio-8080-exec-6] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Using 'application/xml;charset=UTF-8', given [*/*] and supported [application/xml;charset=UTF-8, text/xml;charset=UTF-8, application/*+xml;charset=UTF-8, application/json, application/*+json]

Response content-type is application/xml. This is WRONG. The content type should be application/json.

Behaviour without spring-boot-starter-data-rest dependency

Let try to remove dependency org.springframework.boot:spring-boot-starter-data-rest and repeat the last HTTP request again.

curl -v http://127.0.0.1:8080/api/users/test-user-error

*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080
> GET /api/users/test-user-error HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/8.9.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 400
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Wed, 13 Nov 2024 15:56:57 GMT
< Connection: close
<
{"error":"my-custom-error"}* shutting down connection #0

logs

2024-11-13T18:56:57.825+03:00 DEBUG 2720 --- [nio-8080-exec-2] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.demo.UserController#getUserException()
2024-11-13T18:56:57.834+03:00 DEBUG 2720 --- [nio-8080-exec-2] .m.m.a.ExceptionHandlerExceptionResolver : Using @ExceptionHandler com.example.demo.AdviceController#handleError(MyCustomException)
2024-11-13T18:56:57.858+03:00 DEBUG 2720 --- [nio-8080-exec-2] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Using 'application/json', given [*/*] and supported [application/json, application/*+json, application/xml;charset=UTF-8, text/xml;charset=UTF-8, application/*+xml;charset=UTF-8]

Response content-type is application/json. This is behaviour should be the same with added spring-boot-starter-data-rest dependency.

After logs analyzing the order of supported content types is differ.

The sample application for reproducing the issue is attached below.

Comment From: ivouchak-sc

The sample application for quick reproducing: demo.zip

Comment From: nosan

Thanks for the detailed description and sample, @ivouchak-sc

It does not work with org.springframework.boot:spring-boot-starter-data-rest because RepositoryRestMvcConfigurationadds its HandlerExceptionResolver to the beginning of the list. (See RepositoryRestMvcConfiguration.extendHandlerExceptionResolvers(...)). This HandlerExceptionResolver is configured with default HttpMessageConverter beans and RepositoryRestMvcConfiguration does not re-order them as Spring Boot does in org.springframework.boot.autoconfigure.http.HttpMessageConverters.reorderXmlConvertersToEnd(...).

Since this HandlerExceptionResolver was added to the beginning of the list and converters were not re-ordered you got a response in XML format.

At the moment, to fix this issue, you can add the following bean:


@Bean
RepositoryRestConfigurer reorderHttpMessageConvertersRepositoryRestConfigurer() {
    return new RepositoryRestConfigurer() {
        @Override
        public void configureExceptionHandlerExceptionResolver(ExceptionHandlerExceptionResolver exceptionResolver) {
            List<HttpMessageConverter<?>> messageConverters = exceptionResolver.getMessageConverters();
            reorderXmlConvertersToEnd(messageConverters);
        }
    };
}

private void reorderXmlConvertersToEnd(List<HttpMessageConverter<?>> converters) {
    List<HttpMessageConverter<?>> xml = new ArrayList<>();
    for (Iterator<HttpMessageConverter<?>> iterator = converters.iterator(); iterator.hasNext(); ) {
        HttpMessageConverter<?> converter = iterator.next();
        if ((converter instanceof AbstractXmlHttpMessageConverter) || (converter instanceof MappingJackson2XmlHttpMessageConverter)) {
            xml.add(converter);
            iterator.remove();
        }
    }
    converters.addAll(xml);
}


Honestly, this isn’t the ideal solution. Maybe someone has a better suggestion.

The other option is pretty straightforward:

@Bean
RepositoryRestConfigurer reorderHttpMessageConvertersRepositoryRestConfigurer() {
    return new RepositoryRestConfigurer() {
        @Override
        public void configureExceptionHandlerExceptionResolver(
                ExceptionHandlerExceptionResolver exceptionResolver) {
            exceptionResolver.getMessageConverters().add(0, new MappingJackson2HttpMessageConverter());
        }
    };
}

Comment From: nosan

Maybe, Spring Boot could also re-order HttpMessageConverter in SpringBootRepositoryRestConfigurer for Spring Data REST.

Comment From: bclozel

Thanks for the analysis @nosan, you're spot on.

I think this is an unfortunate combination of several valid opinions:

  1. Spring Boot reorder HttpMessageConverter instances to put XML ones last. Spring Boot more or less considers that XML is a bit out of fashion and unless asked explicitly, other converters should be used first.
  2. Spring Data REST contributes an error handler with specific opinions (including some provided by users). It's ordering this handler first. It's also getting HttpMessageConverter from the application context, as ordered by Spring Framework and the application.

We could consider several options here:

  1. provide your own RepositoryRestConfigurer and reorder converters as you see fit. This is the code snippet shown by @nosan in a comment above
  2. the shorter option somehow works in this case but adds a new JSON converter at the top of the list. This is not the converter that is auto-configured so it's unlikely to honor other auto-configurations and preferences in the app.
  3. Spring Boot reorders the converters for Spring Data REST. Unfortunately, that configuration is very much internal and unpacking this would be brittle. Also, other developers might be relying on this behavior for many years.
  4. Spring Framework changes the default order of converters, putting JSON first. I have created https://github.com/spring-projects/spring-framework/issues/33894 to consider this for Framework 7.0. A major generation is a good fit for such an change.

While I understand the lack of consistency here, the fact is that neither the HTTP client nor the application are expressing any hint for the content negotiation. In this case, I would argue that you can hardly expect any specific content-type in that situation.

Here, the easiest way out in the application would be to set a JSON media type in the @ExceptionHandler if you expect clients to always use JSON. Same goes for REST endpoints.

I also think that setting a default content type (if the content negotiation comes up with nothing) like the following should work:

@Configuration
public class WebConfig implements WebMvcConfigurer {


    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
        configurer.defaultContentType(MediaType.APPLICATION_JSON);
    }
}

Unfortunately, the ExceptionHandlerExceptionResolver here does not set the ContentNegotiationManager that's available globally. Maybe doing so would solve this particular problem transparently. Could you maybe create an issue and discuss that point with the team?

I'm closing this issue since the two most viables options should be explored in other projects.

Thanks!