jsonrustserdereqwestserde-json

How to serialize a struct containing f32 using serde_json?


Relatively new to Rust. I am trying to make an API call which requires the JSON body to be serialized.

The JSON body contains an order_amount key with value which can only take values having INR format 100.36, i.e. Rupees 100 and paise 36. Some more examples 10.48, 3.20, 1.09.

The problem I'm facing is that after serialization with json!() from serde_json, the floating point value becomes something like 100.359765464332.

The API subsequently fails because it expects the order_amount to have only two decimal places.

Here is the code that I have:

The imports

use lambda_runtime::{handler_fn, Context, Error};
use reqwest::header::ACCEPT;
use reqwest::{Response, StatusCode};
use serde_json::json;
use std::env;

#[macro_use]
extern crate serde_derive;

The struct that I'm serializing

#[derive(Serialize, Deserialize, Clone, Debug)]
struct OrderCreationEvent {
    order_amount: f32,
    customer_details: ...,
    order_meta: ...,
}

Eg. The order_amount here has a value of 15.38

async fn so_my_function(
    e: OrderCreationEvent,
    _c: Context,
) -> std::result::Result<CustomOutput, Error> {
let resp: Response = client
        .post(url)
        .json::<serde_json::Value>(&json!(e))
        .send()
        .await?;

After json!(), the amount is being serialized to 15.379345234542. I require 15.38

I read a few articles about writing a custom serializer for f32 which can truncate to 2 decimals, but my proficiency is limited in Rust.

So, I found this code and have been tinkering at it with no luck:

fn order_amount_serializer<S>(x: &f32, s: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    s.serialize_f32(*x)
    // Ok(f32::trunc(x * 100.0) / 100.0)
}

Whether or not the custom serializer is the right approach or solution to the problem, I would still like to learn how to write one, so feel free to enlighten me there too. Cheers! :)


Solution

  • TL;DR it's a floating-point issue coming from serde_json widening f32 to f64. You can reproduce it with code as simple as println!("{}", 77.63_f32 as f64). To fix it, you need to convert to f64, then round, and serialize it as f64:

    s.serialize_f64((*n as f64 * 100.0).trunc() / 100.0)
    

    Detailed explanation

    The problem in the code lies in a different place than where you think it is - it has to do with floating-point precision and not with serde. When you write something like:

    let lat = 77.63_f32;
    

    ...you instruct the compiler to convert the fraction 7763/100 into f32. But that number cannot be exactly represented by an f32 because f32 (like all binary floating-point types) uses binary fractions, i.e. rationals whose denominators are powers of two within some size limits. Given those constraints, 7763/100 gets approximated as 10175119/2**17.1 If you try to print that f32 value, you'll get the expected 77.63 output because println!() knows it's printing an f32 where all digits after the 7th one are side effect of approximation and to be discarded.

    serde_json works differently - it serializes f32 values by converting them to f64 because that is the precision used by JSON and JavaScript. The unfortunate consequence of that is that the 10175119/2**17 approximation of 77.63_f32 gets widened to f64 without the context of the original desire to store 77.63. The f64 simply stores the approximation (which it can accommodate exactly, without further loss of precision), and when you print the resulting f64, you get 77.62999725341797, that's what 10175119/2**17 looks like in decimal to 16 digits of precision.

    This is why implementing a custom serialize as s.serialize_f32(f32::trunc(*x * 100.0) / 100.0) has no effect - you rounded an f32 to two decimal digits (which is in your program a no-op because it was rounded to begin with), and then you passed it to serialize_f32(). serialize_f32() proceeds to widen the f32 value to f64 which makes the extra digits from the f32 approximation visible - and you're back to where you started from with the implementation generated by serde.

    The correct version must convert f32 to f64, then get rid of the extra digits in f64 type, and then pass it to serialize_f64() for printing:

    s.serialize_f64((*n as f64 * 100.0).trunc() / 100.0)
    

    Playground

    That works because: the number 77.63_f32 gets converted to the f64 that corresponds to 10175119/2**17 (i.e. not 77.63_f64, which would be approximated2 to 682840701314007/2**43). This number then gets rounded to two digits in f64, and that rounding produces the closest approximation of 77.63 that f64 is capable of. I.e. now we get the same 682840701314007/2**43 approximation we'd get by using 77.63_f64 in Rust source code. That's the number that serde will work with, and serde_json will format it as 77.63 in JSON output.

    Side note: the above code uses trunc() following the attempt in the question, but maybe round() as shown here would be a more appropriate choice.


    1 You can obtain this ratio with this Python one-liner:

    >>> numpy.float32("77.63").as_integer_ratio()
    (10175119, 131072)
    

    2 Also obtained using Python:

    >>> n = 10175119/131072
    >>> rounded = round(n*100.0)/100.0
    >>> rounded
    77.63
    >>> rounded.as_integer_ratio()
    (682840701314007, 8796093022208)