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! :)
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)
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)
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)