Search code examples
springspring-bootspring-hateoas

Spring HATEOAS client ignore null links - "Template must not be null or empty!" exception


I'm accessing a HATEOAS API provided by a 3rd party and for some reason the response we receive from them contains links with null values for href. This throws an exception. I can not change the response as I have no control over this API. Is there any way around this problem?

Below is an example of what the JSON looks like:

{
    "_embedded": {
        "example": [{ ... }]
    }
    "_links": {
        "next": {
            "href": null
        },
        "prev": {
            "href": null
        },
        "self": {
            "href": "https://bag.basisregistraties.overheid.nl/api/v1/panden"
        }
    }
}

When I make the request using the RestTemplate I get an IllegalArgumentException with the message "Template must not be null or empty!"

Caused by: java.lang.IllegalArgumentException: Template must not be null or empty!
    at org.springframework.util.Assert.hasText(Assert.java:284)
    at org.springframework.hateoas.UriTemplate.<init>(UriTemplate.java:56)
    at org.springframework.hateoas.Link.<init>(Link.java:94)
    at org.springframework.hateoas.hal.Jackson2HalModule$HalLinkListDeserializer.deserialize(Jackson2HalModule.java:583)
    at org.springframework.hateoas.hal.Jackson2HalModule$HalLinkListDeserializer.deserialize(Jackson2HalModule.java:528)
    at com.fasterxml.jackson.databind.deser.impl.FieldProperty.deserializeAndSet(FieldProperty.java:136)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:369)

I tried serving this JSON locally so I can remove the next and prev links from it, and it works fine. I had a look in the code and it fails because it's trying to do new UriTemplate(href) which does not accept null as a parameter.

We are running Spring Boot version 2.1.8.RELEASE and spring-hateoas version 0.25.2. Below is the code that makes the request:

    ObjectMapper mapper = new ObjectMapper();
    mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    mapper.configure(MapperFeature.DEFAULT_VIEW_INCLUSION, false);
    Jackson2HalModule jackson2HalModule = new Jackson2HalModule();
    mapper.registerModule(jackson2HalModule);

    MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
    converter.setSupportedMediaTypes(MediaType.parseMediaTypes(Arrays.asList("application/json;charset=UTF-8", "application/hal+json")));
    converter.setObjectMapper(mapper);

    RestTemplateBuilder restTemplateBuilder = new RestTemplateBuilder();

    RestTemplate restTemplate = restTemplateBuilder.messageConverters(Collections.singletonList(converter)).build();

    HttpHeaders headers = new HttpHeaders();
    headers.set("Accept", "application/json, application/*+json");
    headers.setContentType(MediaType.APPLICATION_JSON_UTF8);

    HttpEntity<String> entity = new HttpEntity(entity.toString(), headers);

    ParameterizedTypeReference<Resources<Panden>> typeRefDevices = new ParameterizedTypeReference<>() {};
    ResponseEntity<Resources<Panden>> result = restTemplate.exchange(endpoint, HttpMethod.POST, entity, typeRefDevices);;


Solution

  • After spending some time looking through the code of Jackson2HalModule I found a solution for this problem. There is no configuration that I can use to tell the library to simply ignore links that have null values. What I can do instead is override the deserialize() method so that I can add a null check and avoid the problem.

    In order to do this firs I had to create the new link deserializer class. It needs to extend Jackson2HalModule.HalLinkListDeserializer then I just copy & pasted the code from the library source with the modifications I wanted.

    public class CustomHalLinkListDeserializer extends Jackson2HalModule.HalLinkListDeserializer {
    
        @Override
        public List<Link> deserialize(JsonParser jp, DeserializationContext ctxt) 
                                          throws IOException, JsonProcessingException {
    
            List<Link> result = new ArrayList<Link>();
            String relation;
    
            // links is an object, so we parse till we find its end.
            while (!JsonToken.END_OBJECT.equals(jp.nextToken())) {
    
                if (!JsonToken.FIELD_NAME.equals(jp.getCurrentToken())) {
                    throw new JsonParseException(jp, 
                                  "Expected relation name", jp.getCurrentLocation());
                }
    
                // save the relation in case the link does not contain it
                relation = jp.getText();
    
                if (JsonToken.START_ARRAY.equals(jp.nextToken())) {
                    while (!JsonToken.END_ARRAY.equals(jp.nextToken())) {
                        CustomLink link = jp.readValueAs(CustomLink.class);
                        String href = link.getHref() != null ? link.getHref() : "http://dummy";
                        result.add(new Link(href, relation));
                    }
                } else {
                    CustomLink link = jp.readValueAs(CustomLink.class);
                    String href = link.getHref() != null ? link.getHref() : "http://dummy";
                    result.add(new Link(href, relation));
                }
            }
    
            return result;
    
        }
    }
    

    Then, in order to tell the Jackson2HalModule to use the new class I had to create my own MixInAnnotation class where I specify that links are deserialized using CustomHalLinkListDeserializer

    public abstract class CustomResourceSupportMixin extends ResourceSupport {
    
        @Override
        @XmlElement(name = "link")
        @JsonProperty("_links")
        @JsonInclude(JsonInclude.Include.NON_EMPTY)
        @JsonSerialize(using = Jackson2HalModule.HalLinkListSerializer.class)
        @JsonDeserialize(using = CustomHalLinkListDeserializer.class)
        public abstract List<Link> getLinks();
    
    }
    

    The new MixInAnnotation can be set while configuring jackson2HalModule as below

    ObjectMapper mapper = new ObjectMapper();
    Jackson2HalModule jackson2HalModule = new Jackson2HalModule();
    jackson2HalModule.setMixInAnnotation(ResourceSupport.class,
                                             CustomResourceSupportMixin.class);
    mapper.registerModule(jackson2HalModule);