How to deserialize a float value with a localized decimal separator with Jackson
Asked Answered
L

3

13

The input stream I am parsing with Jackson contains latitude and longitude values such as here:

{
    "name": "product 23",
    "latitude": "52,48264",
    "longitude": "13,31822"
}

For some reason the server uses commas as the decimal separator which produces an InvalidFormatException. Since I cannot change the server output format I would like to teach Jackson's ObjectMapper to handle those cases. Here is the relevant code:

public static Object getProducts(final String inputStream) {
    ObjectMapper objectMapper = new ObjectMapper();
    try {
        return objectMapper.readValue(inputStream,
                new TypeReference<Product>() {}
        );
    } catch (UnrecognizedPropertyException e) {
        e.printStackTrace();
    } catch (InvalidFormatException e) {
        e.printStackTrace();
    } catch (JsonMappingException e) {
        e.printStackTrace();
    } catch (JsonParseException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    }
    return null;
}

And here is the POJO:

import com.fasterxml.jackson.annotation.JsonProperty;

public class Product {

    @JsonProperty("name")
    public String name;
    @JsonProperty("latitude")
    public float latitude;
    @JsonProperty("longitude")
    public float longitude;

}

How can I tell Jackson that those coordinate values come with a German locale?


I suppose a custom deserializer for the specific fields as discussed here would be the way to go. I drafted this:

public class GermanFloatDeserializer extends JsonDeserializer<Float> {

    @Override
    public Float deserialize(JsonParser parser, DeserializationContext context)
            throws IOException {
        // TODO Do some comma magic
        return floatValue;
    }

}

Then the POJO would look like this:

import com.fasterxml.jackson.annotation.JsonProperty;

public class Product {

    @JsonProperty("name")
    public String name;
    @JsonDeserialize(using = GermanFloatDeserializer.class, as = Float.class)
    @JsonProperty("latitude")
    public float latitude;
    @JsonDeserialize(using = GermanFloatDeserializer.class, as = Float.class)
    @JsonProperty("longitude")
    public float longitude;

}
Lieselotteliestal answered 3/11, 2014 at 23:38 Comment(5)
What you have is invalid JSON. Have the person providing the JSON supply you with legal stuff.Defrock
@HotLicks Is it invalid because of the , as the decimal separator? In case you meant the trailing comma - that is something I forgot to type in. I fixed it.Lieselotteliestal
The comma decimal point is not valid JSON. You can go to json.org and see the syntax. It's possible that the comma appears because somehow your request to the server implies a locale, and changing that implied locale will fix the problem, but otherwise the folks on the other end should fix it.Defrock
jslint.com says it is valid. However, the server side is not under my control.Lieselotteliestal
Well, I suppose it is valid, because those are strings, not numbers. It's up to you to handle strings however you want. Has nothing to do with JSON, per se. Change public float latitude; to public String latitude;, etc.Defrock
L
12

I came up with the following solution:

public class FlexibleFloatDeserializer extends JsonDeserializer<Float> {

    @Override
    public Float deserialize(JsonParser parser, DeserializationContext context)
            throws IOException {
        String floatString = parser.getText();
        if (floatString.contains(",")) {
            floatString = floatString.replace(",", ".");
        }
        return Float.valueOf(floatString);
    }

}

...

public class Product {

    @JsonProperty("name")
    public String name;
    @JsonDeserialize(using = FlexibleFloatDeserializer.class)
    @JsonProperty("latitude")
    public float latitude;
    @JsonDeserialize(using = FlexibleFloatDeserializer.class)
    @JsonProperty("longitude")
    public float longitude;

}

Still I wonder why I it does not work when I specify the return value class as as = Float.class as can be found in the documentation of JsonDeserialize. It reads as if I am supposed to use one or the other but not both. Whatsoever, the docs also claim that as = will be ignored when using = is defined:

if using() is also used it has precedence (since it directly specified deserializer, whereas this would only be used to locate the deserializer) and value of this annotation property is ignored.

Lieselotteliestal answered 9/11, 2014 at 11:16 Comment(1)
having the same problem, and i'm amazed that theres no neat @JsonFormat("###,##") for doing thisPennington
J
2

With all respect to accepted answer, there is a way to get rid of those @JsonDeserialize annotations.

You need to register the custom deserializer in the ObjectMapper.

Following the tutorial from official web-site you just do something like:

    ObjectMapper mapper = new ObjectMapper();
    SimpleModule testModule = new SimpleModule(
            "DoubleCustomDeserializer",
            new com.fasterxml.jackson.core.Version(1, 0, 0, null))
            .addDeserializer(Double.class, new JsonDeserializer<Double>() {
                @Override
                public Double deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException {
                    String valueAsString = jp.getValueAsString();
                    if (StringUtils.isEmpty(valueAsString)) {
                        return null;
                    }

                    return Double.parseDouble(valueAsString.replaceAll(",", "\\."));
                }
            });
    mapper.registerModule(testModule);

If you're using Spring Boot there is a simpler method. Just define the Jackson2ObjectMapperBuilder bean somewhere in your Configuration class:

@Bean
public Jackson2ObjectMapperBuilder jacksonBuilder() {
    Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();

    builder.deserializerByType(Double.class, new JsonDeserializer<Double>() {
        @Override
        public Double deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException {
            String valueAsString = jp.getValueAsString();
            if (StringUtils.isEmpty(valueAsString)) {
                return null;
            }

            return Double.parseDouble(valueAsString.replaceAll(",", "\\."));
        }
    });

    builder.applicationContext(applicationContext);
    return builder;
}

and add the custom HttpMessageConverter to the list of WebMvcConfigurerAdapter message converters:

 messageConverters.add(new MappingJackson2HttpMessageConverter(jacksonBuilder().build()));
Jacal answered 27/5, 2015 at 12:42 Comment(4)
Does this belong here? and add the custom HttpMessageConverter to the list of WebMvcConfigurerAdapter message converters:Lieselotteliestal
yeah, by default SpringBoot does not take into account Jackson2ObjectMapperBuilder Bean. If you take a look on how the default WebMvcConfigurerAdapter is initialized you will note that it uses Jackson2ObjectMapperBuilder.json() static method instead of Bean injectionJacal
This is the best answer by farScabbard
Using custom deserializer globally using a custom jackson's module doesn't always fit the bill. But, indeed, if you need this deserializer applied globally this is the best way of doing it. But, there are cases when you have the same information type with different formats for different json payloads, such as, say, ZonedDateTime, or currency. In this cases I think a good balance between custom module, custom mixin and custom field deserializer, plus considering the precendence of such defined deserializer, is, IMHO, a way to approach itSectarianism
Q
2

A more general solution than the other proposed answers, which require registering individual deserializers for each type, is to provide a customized DefaultDeserializationContext to ObjectMapper.

The following implementation (which is inspired by DefaultDeserializationContext.Impl) worked for me:

class LocalizedDeserializationContext extends DefaultDeserializationContext {
    private final NumberFormat format;

    public LocalizedDeserializationContext(Locale locale) {
        // Passing `BeanDeserializerFactory.instance` because this is what happens at
        // 'jackson-databind-2.8.1-sources.jar!/com/fasterxml/jackson/databind/ObjectMapper.java:562'.
        this(BeanDeserializerFactory.instance, DecimalFormat.getNumberInstance(locale));
    }

    private LocalizedDeserializationContext(DeserializerFactory factory, NumberFormat format) {
        super(factory, null);
        this.format = format;
    }

    private LocalizedDeserializationContext(DefaultDeserializationContext src, DeserializationConfig config, JsonParser parser, InjectableValues values, NumberFormat format) {
        super(src, config, parser, values);
        this.format = format;
    }

    @Override
    public DefaultDeserializationContext with(DeserializerFactory factory) {
        return new LocalizedDeserializationContext(factory, format);
    }

    @Override
    public DefaultDeserializationContext createInstance(DeserializationConfig config, JsonParser parser, InjectableValues values) {
        return new LocalizedDeserializationContext(this, config, parser, values, format);
    }

    @Override
    public Object handleWeirdStringValue(Class<?> targetClass, String value, String msg, Object... msgArgs) throws IOException {
        // This method is called when default deserialization fails.
        if (targetClass == float.class || targetClass == Float.class) {
            return parseNumber(value).floatValue();
        }
        if (targetClass == double.class || targetClass == Double.class) {
            return parseNumber(value).doubleValue();
        }
        // TODO Handle `targetClass == BigDecimal.class`?
        return super.handleWeirdStringValue(targetClass, value, msg, msgArgs);
    }

    // Is synchronized because `NumberFormat` isn't thread-safe.
    private synchronized Number parseNumber(String value) throws IOException {
        try {
            return format.parse(value);
        } catch (ParseException e) {
            throw new IOException(e);
        }
    }
}

Now set up your object mapper with your desired locale:

Locale locale = Locale.forLanguageTag("da-DK");
ObjectMapper objectMapper = new ObjectMapper(null,
                                             null,
                                             new LocalizedDeserializationContext(locale));

If you use Spring RestTemplate, you can set it up to use objectMapper like so:

RestTemplate template = new RestTemplate();
template.setMessageConverters(
    Collections.singletonList(new MappingJackson2HttpMessageConverter(objectMapper))
);

Note that the value must be represented as a string in the JSON document (i.e. {"number": "2,2"}), since e.g. {"number": 2,2} is not valid JSON and will fail to parse.

Quotable answered 26/9, 2016 at 13:34 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.