Quantcast
Channel: Virgo's Naive Stories
Viewing all articles
Browse latest Browse all 15

Spring Boot and HTTP message converters – no Jackson, no defaults

$
0
0

I’m afraid this post will get a tad longer – if for nothing else then for the code and output listings. So let’s state what we are to achieve here:

  • Implementing custom HTTP message converters for custom content type and class.
  • Avoiding default converters altogether or adding them at the end of the converter list to favour our converters in case of ambiguity.

If you’re interested in this topic don’t let the length of the post scare you – it’s not that long without the code and other console output. I know there are Baeldung tutorials or StackOverflow but we will conduct a series of small experiments starting with a minimal Spring Boot application with a few REST methods. We will iteratively analyze the configuration and behavior and with a series of small changes get where we want to. It should be fun, don’t worry.

Spring Boot 2.2, and hence Spring Framework 5.2, was used for the test project.

Note: My wife was laughing every time she asked me “is your blog post finished already?” and I answered “not yet!” I was thinking about splitting it, but somehow I believe it belongs together.

No Jackson?!

There is nothing wrong with Jackson and its support in Spring MVC. However, this time we will not focus on these quite specialized implementations of HttpMessageConverter. We will mix one Jackson converter into our examples just to illustrate what is in and what is out of scope.

This is by far not the first time I’m trying to tame Spring converters and soon I’ll have to do the same with exception handlers – again not the first time. (Will that result in a post? I hope not. :-)). The problem I’m solving seems to fit Jackson actually. I need to convert a specific hierarchy of objects to/from XML, JSON and YAML. This is supported by Jackson (OK, XML is more complicated with more options like JAXB, but let’s not talk about that now), but our implementation is quite specific – and already working fine. The only thing I’d use from Jackson is to read/write raw forms of these messages. In that case we can go that level lower and avoid additional complexity of ObjectMapper and JsonSerializer. Not to mention that the latter is the root of historically rather a crooked hierarchy.

In this post we will not go through all the various converter implementations provided by Spring either – it would be too much and distracting at the moment. The point is how to get the converter in and how to have only the right converters working – not how to write one.

Simple default Spring Boot REST application

Let’s start with Spring Initalizr – we will use Spring Boot 2.2 and Spring Web. Click on this link, you may change the Java version and build tool to your liking as we will run everything manually, preferably in IDE for easier debugging.

We will conduct each experiment in a separate package. @SpringBootApplication by default scans under the package of the main class which suits us well and allows for higher “experiment density” so to say. Obviously, one slip with autoscan can ruin it all, but we will not play with scanning options at all.

You can check the first case in the companion GitHub project. The application starter is typical:

@SpringBootApplication
public class DemoApp1 {
  public static void main(String[] args) {
    SpringApplication.run(DemoApp1.class, args);
  }
}

We don’t have any configuration or customization – just the controller class:

@RestController
public class DemoController1 {
  @GetMapping
  public String hello() {
    return "Hi, it's " + LocalTime.now();
  }

  @GetMapping("/list")
  public Collection<?> list() {
    return List.of(
      "String1",
      List.of("Substring1", "Substring2")
    );
  }

  // DEBUG area
  @Autowired private List<WebMvcConfigurer> configurers;
  @Autowired private List<HttpMessageConverter<?>> converters;
  @Autowired private RequestMappingHandlerAdapter requestMappingHandlerAdapter;

  @GetMapping("config")
  public Map<?, ?> config() {
    return Map.of(
      "configurers", toStrings(configurers),
      "converters", toStrings(converters),
      "requestMappingHandlerAdapter-converters",
      toStrings(requestMappingHandlerAdapter.getMessageConverters()));
  }

  private Object toStrings(Collection<?> collection) {
    return collection != null
      ? collection.stream().map(Object::toString).collect(Collectors.toList())
      : "N/A";
  }
}

Run the application and let’s see what is happening. I’m a fan of HTTPie so if you like curl you have to “translate” the commands. Or use any other client, we will use only the GET method, so even a browser with a web developer panel (F12 typically) is enough. Commands follow the $ prompt, the rest is output.

$ http --print=b :8080 # prints body without response headers
Hi, it's 11:50:15.104575100

$ http :8080/list # let’s see the headers too
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Mon, 11 May 2020 09:51:23 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

[
    "String1",
    [
        "Substring1",
        "Substring2"
    ]
]

JSON obviously works out of the box – what about XML?

$ http :8080/list "Accept: text/xml"
HTTP/1.1 200
Connection: keep-alive
Content-Type: text/xml;charset=UTF-8
Date: Mon, 11 May 2020 09:55:10 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

<Collection><item>String1</item><item>Substring1</item><item>Substring2</item></Collection>

Works too! Let’s see what our “debug” section has to say:

$ http -v :8080/config # let's see also request headers
GET /config HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8080
User-Agent: HTTPie/2.0.0

HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Mon, 11 May 2020 09:53:09 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

{
    "configurers": [
        "org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration$WebMvcAutoConfigurationAdapter@17f8f7e2"
    ],
    "converters": [
        "org.springframework.http.converter.StringHttpMessageConverter@59901c4d",
        "org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter@168cd36b",
        "org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@d8d9199"
    ],
    "requestMappingHandlerAdapter-converters": [
        "org.springframework.http.converter.ByteArrayHttpMessageConverter@45acdd11",
        "org.springframework.http.converter.StringHttpMessageConverter@59901c4d",
        "org.springframework.http.converter.StringHttpMessageConverter@3f0d6038",
        "org.springframework.http.converter.ResourceHttpMessageConverter@237f7970",
        "org.springframework.http.converter.ResourceRegionHttpMessageConverter@58f39564",
        "org.springframework.http.converter.xml.SourceHttpMessageConverter@7b948f3e",
        "org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter@2f4c2cd4",
        "org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@d8d9199",
        "org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@77a074b4",
        "org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter@168cd36b",
        "org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter@333c8791"
    ]
}

A few observations:

  • There is one WebMvcConfigurer that helps with MVC customization – this one is part of the MVC auto-configuration. We will use this interface later.
  • There are some duplicated converters – like the string, JSON and XML.
  • JSON takes precedence over XML when Accept: */* is used.

I honestly don’t know why there are some converters twice. We sure can find out what is causing it (that’s one “why”), but that doesn’t answer the true motivation (the more important “why”). Just put a breakpoint in the StringHttpMessageConverter(Charset) constructor and you’ll see what is causing the creation.

Little variations of default application

Spring – and especially Spring Boot applications – are kinda volatile when you make minuscule configuration changes. Single annotation here or there and everything is completely different. Now, sure we should read reference documentation, but often we just guess. I don’t like guessing, but I like experimenting on simple examples – however close to guessing it is.

Spring gives us a lot of auto-magic, sometimes too much. But I encourage you to try it, debug it and read the sources and their javadocs as you go. I’ve worked with Spring for nearly a decade now and while it sometimes bothered me how little changes could have a huge impact, I never could deny that the source code was readable and well documented. Compared to many other libraries, I always felt better when I stepped through the Spring code.

No MVC auto-configuration

So let’s try a few changes and combine some black-box/white-box observations as we go.

In the first variation (demo1b) we merely add (exclude = WebMvcAutoConfiguration.class) to our @SpringBootApplication annotation on the main class.

Some changes in the controller class are also required – we need to make some autowiring optional and check for null in our /config debug method. Spring can’t return anything complex now – objects are not serialized to neither JSON nor XML. Calling /list or /config without changes would fail on internal error about no converters found for the returning type. Knowing this we rather make the /config print the information to stdout.

The information printed is this (manually formatted):

{configurers=N/A,
converters=[
org.springframework.http.converter.StringHttpMessageConverter@5580d62f,
org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter@16872c4d,
org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@671facee],
requestMappingHandlerAdapter-converters=N/A}

So there are no configurers found this time. There are three converter components found, although this doesn’t mean they are the only three usable – just compare this to the previous example where it was the same. We’ll talk about this later. Actually, both JSON and XML converters are there – but they are not used. But the String converter is somehow available through other means as accessing the root endpoint still returns the “Hi…” string.

But one thing is sure, without MVC auto-configure and anything else in its place not much is working – although the controller is still called. Let’s try something more meaningful.

Enabling MVC with annotation

This time (demo1c) we make a different simple change – we will add @EnableWebMvc on the main class (with no exclude this time). Controller can return the objects again as there are some useful converters available.

You’ll notice that by default /list and /config returns XML, so we’ll add -j to HTTPie to force JSON:

$ http -jv :8080/config
GET /config HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Type: application/json
Host: localhost:8080
User-Agent: HTTPie/2.0.0

HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Mon, 11 May 2020 14:13:41 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

{
    "configurers": "N/A",
    "converters": [
        "org.springframework.http.converter.StringHttpMessageConverter@1cb37ee4",
        "org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter@5bcb04cb",
        "org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@385e36d4"
    ],
    "requestMappingHandlerAdapter-converters": [
        "org.springframework.http.converter.ByteArrayHttpMessageConverter@55d5d6c9",
        "org.springframework.http.converter.StringHttpMessageConverter@51ea48ab",
        "org.springframework.http.converter.ResourceHttpMessageConverter@14e8dcf2",
        "org.springframework.http.converter.ResourceRegionHttpMessageConverter@5e2e394f",
        "org.springframework.http.converter.xml.SourceHttpMessageConverter@2536cfdd",
        "org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter@261a74c0",
        "org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter@74c723bd",
        "org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@5b0c882b"
    ]
}

Well, obviously XML is above JSON converter and both match */*, so now we know why it returns XML. Why is it above? I don’t know and I don’t want to know because we’re still not in a state where we are fully in control. We’ll get to that later.

Notice that:

  • There are no configurers (WebMvcConfigurer implementations), so this is MVC with little to no customization. Spring Boot included auto-configuration does a lot for us, obviously.
  • But there are also no duplicates in converters! Hurray!
  • Funny enough, no converter is really missing, so it seems, just the order is different.

So these are auto-magic cases without any customization. Next we will try to step in and configure the things to our linking. But before that…

Theory intermezzo

We already talked about HttpMessageConverter and how any Jackson support is a matter of various implementations. I also mentioned that this post is not about how to implement the converter. But we will implement one very silly converter to demonstrate our point. It will be part of our configuration class, that’s why it’s private static nested class:

private static class Crazy1Converter<T> extends AbstractHttpMessageConverter<T> {
  private final String name;
  private final Class<T> supportedClass;

  protected Crazy1Converter(String name, Class<T> supportedClass) {
    super(CRAZY1); // our crazy/1 content type
    this.name = name;
    this.supportedClass = supportedClass;
  }

  @Override
  protected boolean supports(Class clazz) {
    return supportedClass.isAssignableFrom(clazz);
  }

  @Override
  public boolean canRead(Class<?> clazz, MediaType mediaType) {
    return false;
  }

  @Override
  protected T readInternal(Class<? extends T> clazz, HttpInputMessage inputMessage)
  throws HttpMessageNotReadableException {
    throw new UnsupportedOperationException("This converter can only write objects, not read");
  }

  @Override
  protected void writeInternal(T o, HttpOutputMessage outputMessage)
  throws HttpMessageNotWritableException, IOException {
    StreamUtils.copy("Converted by " + name + " to " + o.getClass().getName() + ": " + o.toString(),
      StandardCharsets.UTF_8, outputMessage.getBody());
  }
}

To make our task easier we extended from AbstractHttpMessageConverter and only needed to implement three methods. This converter will be considered if the accepted media type(s) match the one (or list) specified in the constructor – in our case we use content type crazy/1 – and if the class is supported. To support virtually any class by the implementation we merely write some string information about the object to be written to HTTP (response). We don’t support reading at all, returning false in canRead() override (there are two overloads, but only one implementing the interface), so this converter is not usable for reading.

Now to apply this we have a few options (and I may not be exhausting) – we can extend from WebMvcConfigurationSupport or implement WebMvcConfigurer. We will try both ways later, but they are confusingly similar in a few aspects. Imagine you implement the interface – it has default methods so you don’t have to implement any nowadays, but you’ll override a method like extendMessageConverters(). Later you’ll switch this configuration to extend from WebMvcConfigurationSupport. Nothing gets broken – on the compilation level. You may even think that the “support” class is an implementation of the interface. But it isn’t!

I found this short summary about the differences between these – it’s a bit older and uses WebMvcConfigurerAdapter which is now deprecated in favour of WebMvcConfigurer interface as it now provides all its functionality via those default methods.

Read also javadoc for @EnableWebMvc for more information. It may be confusing that you can add this annotation to a @Configuration class implementing WebMvcConfigurer, but it doesn’t work the same way if you add @EnableWebMvc and @Configuration on a class extending WebMvcConfigurationSupport.

Finally – content negotiation – this is the job of configureContentNegotiation() method:

  • You have to register custom content types to an extension with configurer.mediaType() if you want to use a query parameter or a path extension for content type negotiation.
  • If the Accept header is all you want you don’t even need that.
  • You can also set default content type(s) to use, e.g. when */* is accepted. We’ve seen that depending on the configuration XML may be preferred response content type. Multiple types can be specified in preferred order.

I’m pretty sure we’re eager to write some configuration, so let’s do it.

Our first custom configuration

See demo2 example for the full code, we will focus on the most important changes only. First we will get back to the default application without @EnableWebMvc. We will let the auto-configuration do its magic, but we will mix one @Configuration class implementing WebMvcConfigurer:

@Configuration
public class DemoConfig2 implements WebMvcConfigurer {

  private static final MediaType CRAZY1 = MediaType.valueOf("crazy/1");

  @Override
  public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
    System.out.println("configureMessageConverters.size(): " + converters.size());
    converters.add(new Crazy1Converter<>("first", String.class));
    converters.add(new Crazy1Converter<>("second", Object.class));
  }

  @Override
  public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
    configurer.mediaType(CRAZY1.getSubtype(), CRAZY1);
    configurer.defaultContentType(MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML);
  }
// Crazy1Converter class goes here
}

Let’s try it in the black box fashion first:

$ http :8080/config
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Tue, 12 May 2020 20:30:07 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

{
    "configurers": [
        "org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration$WebMvcAutoConfigurationAdapter@129bd55d",
        "com.example.demo2.DemoConfig2$$EnhancerBySpringCGLIB$$2bfd7cba@38af1bf6"
    ],
    "converters": [
        "org.springframework.http.converter.StringHttpMessageConverter@1de0a46c",
        "org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter@4d1f1ff5",
        "org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@222afc67"
    ],
    "requestMappingHandlerAdapter-converters": [
        "org.springframework.http.converter.ByteArrayHttpMessageConverter@2975a9e",
        "org.springframework.http.converter.StringHttpMessageConverter@1de0a46c",
        "org.springframework.http.converter.StringHttpMessageConverter@765ffb14",
        "org.springframework.http.converter.ResourceHttpMessageConverter@57562473",
        "org.springframework.http.converter.ResourceRegionHttpMessageConverter@7a360554",
        "org.springframework.http.converter.xml.SourceHttpMessageConverter@424de326",
        "org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter@4bc33720",
        "org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@222afc67",
        "org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@2dd0f797",
        "org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter@4d1f1ff5",
        "org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter@67064bdc",
        "com.example.demo2.DemoConfig2$Crazy1Converter@203d471f",
        "com.example.demo2.DemoConfig2$Crazy1Converter@7d1f95ad"
    ]
}

Obviously, JSON works. We will get back to the content in a second, but let’s try our crazy/1 content type (wrapped and shortened):

$ http :8080/config "Accept: crazy/1"
HTTP/1.1 200
Connection: keep-alive
Content-Type: crazy/1
Date: Tue, 12 May 2020 20:30:46 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

Converted by second to java.util.ImmutableCollections$MapN: {converters=[org.springframework.http.converter.StringHttpMessageConverter@1de0a46c,
org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter@4d1f1ff5,
...etc.

So this worked too – and we can also see it picked the “second” converter, as the first one was compatible only with String. So let’s try it on our “Hi” message:

$ http :8080 "Accept: crazy/1"
HTTP/1.1 200
Connection: keep-alive
Content-Length: 27
Content-Type: crazy/1;charset=UTF-8
Date: Tue, 12 May 2020 20:34:27 GMT
Keep-Alive: timeout=60

Hi, it's 22:34:27.377581400

Oh, that’s a bummer. It obviously used the default StringHttpMessageConverter. Let’s read the config again (printed a bit above), we can see that:

  • There are duplicated converters – just like in our very first demo.
  • Our converters were added at the end of the list.

If we also check the stdout of the program we will see the size of the converter list parameter. It’s not 0 as we could expect after reading javadoc for configureMessageConverters(). It actually acts the same way like extendMessageConverters() now – why?

Again, you can debug it as the answers are just a few steps after the call is finished in the class WebMvcConfigurationSupport and its method getMessageConverters(). The list is the result of combining all our configurers (see DelegatingWebMvcConfiguration) and they are two now – the default one like before and our new config class. This is also clear from our /config output.

Custom config with @EnableWebMvc

We will change only one thing in demo2b – we will add @EnableWebMvc to our main class. First thing we see during the start that the list of converters came empty. Let’s see what /config has to say about it:

$ http :8080/config
HTTP/1.1 406
...

Oh, 406 – that is Not Acceptable – which means it doesn’t know how to convert the result. Also see the console output for the warning message. Let’s help it (output wrapped):

$ http :8080/config "Accept: crazy/1"
HTTP/1.1 200
Connection: keep-alive
Content-Type: crazy/1
Date: Tue, 12 May 2020 20:46:01 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

Converted by second to java.util.ImmutableCollections$MapN: {requestMappingHandlerAdapter-converters=[
com.example.demo2b.DemoConfig2b$Crazy1Converter@113031cc,
com.example.demo2b.DemoConfig2b$Crazy1Converter@5432ad8e],
configurers=[
com.example.demo2b.DemoConfig2b$$EnhancerBySpringCGLIB$$fc54875a@150fa7b5],
converters=[
org.springframework.http.converter.StringHttpMessageConverter@3efedc6f,
org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter@45bf6f39,
org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@6c42f2a
]}

Whatever converters are available as components, they are not used because we did nothing about it. Our crazy converters are available – and working just as in the previous demo. Let’s try “Hi” now with this type:

$ http :8080 "Accept: crazy/1"
HTTP/1.1 200
...
Converted by first to java.lang.String: Hi, it's 22:44:21.269519200

Great. Can we make this a default type? We already mentioned the method configureContentNegotiation(), it’s also in our DemoConfig2b class, but the key line is commented out. Just enable it and rerun the demo and there is no need to specify the Accept header.

With this demo we have now total control over our converters but perhaps we want all the default ones too (not necessarily always true), but with ours taking the lead. There are a couple of ways to do it:

  • We can inject converters registered as components into the config class and add them to the list.
  • We can create the converters programmatically and add them to the list.
  • Or we can switch to WebMvcConfigurationSupport which behaves a bit differently.

But before we do that, let’s see what happens if we register our converters as beans.

Converters registered as beans

This is shown in demo2c with @EnableWebMvc and in demo2d with auto-configuration. Let’s see how it works with @EnableWebMvc first. We changed the configuration class to include the following lines:

@Autowired
private List<Crazy1Converter<?>> crazy1Converters;

@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
  System.out.println("configureMessageConverters.size(): " + converters.size());
}

@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
  System.out.println("extendMessageConverters.size(): " + converters.size());
  converters.addAll(crazy1Converters);
}

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

@Bean
public Crazy1Converter<String> crazyStringConverter() {
  return new Crazy1Converter<>("first", String.class);
}

@Bean
public Crazy1Converter<Object> crazyObjectConverter() {
  return new Crazy1Converter<>("second", Object.class);
}

With @Autowire we can inject beans that are created in the same configuration class – this obviously is not possible with constructor injection unless you use two configuration classes. We manually add these converters in extendMessageConverters() as we also want other default converters. The console output prints:

configureMessageConverters.size(): 0
extendMessageConverters.size(): 8

There is no composite configuration this time, everything works just as javadoc says. If we added converters in configureMessageConverters() no additional converters would be added. The drawback is that our converters are still at the end of the list. As we’re not using auto-configuration the list of converters is smaller – and arguably cleaner.

Now what would happen with auto-configuration? Main class is the same without @EnableWebMvc annotation, so it looks like in a default Spring Boot application again. If we tried this with the previous configuration class we would see the following behaviour. I’ll use HTTPie with -j switch as JSON is way more readable than our crazy format (output manually modified):

$ http -j :8080/config
HTTP/1.1 200
...
{
"configurers": [ 2 as typical with auto-configuration ],
"converters": [
  "com.example.demo2c.DemoConfig2c$Crazy1Converter@58cf8f94",
  "com.example.demo2c.DemoConfig2c$Crazy1Converter@6e33fcae",
  + 3 typical default ones
],
"requestMappingHandlerAdapter-converters": [
  "com.example.demo2c.DemoConfig2c$Crazy1Converter@58cf8f94",
  "com.example.demo2c.DemoConfig2c$Crazy1Converter@6e33fcae",
...the rest of default ones… and…
  "com.example.demo2c.DemoConfig2c$Crazy1Converter@58cf8f94",
  "com.example.demo2c.DemoConfig2c$Crazy1Converter@6e33fcae"
]}

Now that’s a surprise! Our crazy converters lead the pack and it seems we don’t have to add them manually at all. (You can get this output yourselves if you try demo2c with @EnableWebMvc commented out.)

Let’s try to drop both *MessageConverters() methods from the config class, auto-configuration will manage this just fine. We still need configureContentNegotiation() if mapping with path extensions and/or query parameters is required.

Notice that the order of beans matters. If you flip it, the “first” converter (for String) will never apply as the “second” one (but first in order) will work for all Objects, that is for Strings too.

This is demo2d. Restart and observe:

  • /config returns crazy/1 by default, but works with JSON and XML as well – great.
  • hello() exposed at / returns crazy/1 by default, but with Accept: text/plain returns String as expected.

We probably don’t like this behaviour that much and JSON would be a better default, so I left commented configureContentNegotiation() method in the config after all. But this time it does no media type registration and only sets default type for negotiation to JSON.

This solution seems working very well and the config class gets really minimal. But let’s address the elephant in the room too. Let’s extend from WebMvcConfigurationSupport for a change.

Extending WebMvcConfigurationSupport

In demo3 we will make a small change – instead of implements WebMvcConfigurer we will use extends WebMvcConfigurationSupport in our DemoConfig3 class. We will leave our converters registered as beans and add some debug output.

With a lot of expectation we call /config:

$ http :8080/config
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Thu, 14 May 2020 07:37:47 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

{
  "configurers": "N/A",
  "converters": [
      "com.example.demo3.DemoConfig3$Crazy1Converter@319c3a25",
      "com.example.demo3.DemoConfig3$Crazy1Converter@238bfd6c",
      "org.springframework.http.converter.StringHttpMessageConverter@1317b708",
      "org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter@6438a7fe",
      "org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@2e51d054"
  ],
  "requestMappingHandlerAdapter-converters": [
      "org.springframework.http.converter.ByteArrayHttpMessageConverter@74b0da8b",
      "org.springframework.http.converter.StringHttpMessageConverter@439142c0",
      "org.springframework.http.converter.ResourceHttpMessageConverter@ba03673",
      "org.springframework.http.converter.ResourceRegionHttpMessageConverter@6fe3c7c8",
      "org.springframework.http.converter.xml.SourceHttpMessageConverter@1df52c3d",
      "org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter@543bea01",
      "org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter@5ea25d5b",
      "org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@2f0e1cc0"
  ]
}

Alas, our crazy converters are not active even though they are found as beans. You may notice that none of the beans (converters) are not in the requestMappingHandlerAdapter-converters. What happened?

The answer is in WebMvcConfigurationSupport and its method configureMessageConverters() – there’s nothing there. If we don’t extend the support class ourselves, Spring Boot uses the default implementation EnableWebMvcConfiguration, extending DelegatingWebMvcConfiguration and transitively WebMvcConfigurationSupport, nested in WebMvcAutoConfiguration. Method configureMessageConverters() (implemented in DelegatingWebMvcConfiguration) calls the method on all found WebMvcConfigurers and the one provided by Boot by default uses a provider of converters (managed by Spring) and adds them to the list. This method is called from the already mentioned getMessageConverters() when the list is lazily initialized.

Notice also from the /config output that there are no configurers (classes implementing WebMvcConfigurer). Even the default one (WebMvcAutoConfigurationAdapter, again a nested class in WebMvcAutoConfiguration) is disabled the moment we provide our implementation of WebMvcConfigurationSupport.

The interaction between these two types causes some confusion, reading Javadoc of the classes and @EnableWebMvc annotation may help, but it doesn’t sink in easily.

To put it overly simply:

One WebMvcConfigurationSupport to rule them all – that is all the WebMvcConfigurer implementations.

If you provide the support class and no configurer, you can do everything in it. But you can also leave default support class and let it find your configurer (and any others including the default one) and mix it together. Or you can implement both support class and configurer(s). And then, behaviour may change in Spring Boot when you add @EnableWebMvc. All defaults mentioned above are part of Spring Boot auto-configuration and Spring without Boot does not have it.

One more support experiment

There are some things available on the support class that are not on the WebMvcConfigurer interface. Let’s try demo3b where we don’t bother with converters as beans and still can put them where we want them – and add default ones as well – all using configureMessageConverters() method:

public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
  converters.add(new Crazy1Converter<>("first", String.class));
  converters.add(new Crazy1Converter<>("second", Object.class));
  addDefaultHttpMessageConverters(converters);
}

That call addDefaultHttpMessageConverters() is not available on the interface, so that’s something we gained with extending from the support class. It does not add 3 registered converter beans, but all three types are added – just different instances.

Now, perhaps, is a good time to ask how important it is to register a converter as a bean? This depends whether you need it as a bean really. Do you plan to inject it somewhere outside of the configuration class where you set it up? If not then it’s not necessary really.

Or you may need to inject something into the converter. This is quite typical for more complicated converters. But you can just inject all the necessary stuff via constructor and call it from configuration class where the framework-based injection happens. So more often you can manage your converters on your own without bothering poor Spring that already has hundreds of beans to take care of in a typical application anyway.

There is more!

Of course there is. But at this moment what strategy would I use? I like either the last one – total control with extending WebMvcConfigurationSupport or implementing WebMvcConfigurer without disabling auto-configuration, unless it is somehow disabled in the (perhaps longer existing) application. Then you have to add @EnableWebMvc again, but not on the support class. It will still work as a @Configuration class, beans will be created, but it will not configure itself properly. Put @EnableWebMvc somewhere else or use WebMvcConfigurer.

I’d personally add to the default configuration as you have most of the stuff working already and implement the interface without bothering making converters beans unless I’d needed them elsewhere.

By the way – you can mess with the converter list even in extendMessageConverters(). Try to add converters.clear() into any demo, it will likely fail as the Spring considers it illegal to have no converters. But it also means that you can reorganize them there although the code probably will not look pretty (would you test them for their types?). Also, the order of the converters is often not that important, mostly we don’t have more converters for the same type – but if it is, now you know at least two ways to work it out.

We talked only about conversion, not about many other related aspects. Converter does not have any information if the converted argument is annotated, you probably need argument resolvers for that (in JAX-RS it’s all in a converter, BTW). Then there is an error (exception) handling.

I didn’t talk about Jackson much, but you can easily add YAML support with it. XML actually is available in the Spring Boot web application by default. You may want to add produces and/or consumes attributes to your mappings, per controller class or method, but I didn’t bother in my examples. If you want to support multiple content types with some universal converters, I’d not limit myself with these attributes unless you really need it. If the converter is not found it will return HTTP 406 Not Acceptable – just as it will return this if the type is not registered or if you use produces attribute and forget some types there. There is a tiny difference in behaviour though – if there is a converter for the type it will be used for rendering error, if there is none, the message is empty.

Conclusion

So it was really long, but I hope you appreciate the step by step approach. What I appreciate is Spring and Spring Boot. For all its auto-configuration I’m still able to get the answers, the code is clean and javadoc sufficient. I’ve seen worse more often than not, so kudos to Spring.

I hope this helps to make the right decision. There is not a single one, really. If you don’t have to, don’t fight Boot and go with the flow – add your WebMvcConfigurer implementation, add converters as bean or not, don’t overdo it with consumes/produces restrictions unless necessary and you should be fine.

Final checklist:

  • Is your configuration kicking in? Are annotations on proper classes? This is always easy to check with breakpoints (or logging) in methods you expect to run. This is the cause of most broken expectations, so be sure things you think happen really happen.
  • Is your content type in the list of consumes/produces if you use them? (“Consumes” was not covered in this post.) Do you need to state this attribute at all?
  • Did you register your media type in configureContentNegotiation() if you use path/request params for negotiation? (Also not covered here.)
  • Don’t be afraid to experimentally inject internal Spring beans! I’ve learned a lot while writing this post with my “debug” section in the controller.

If you find some misinformation, please, let me know. I fixed some already as I learned more as I was writing. I found some unexplained or misleading information online too, so it’s easy to write wrong stuff based on other wrong stuff.

And that’s it for now! I’m just checking the title… we really did most of it without Jackson and we’ve also disabled default converters in some configurations. After probably the most demanding post of my life I wasn’t sure it would still fit – but it does.


Viewing all articles
Browse latest Browse all 15

Trending Articles