use rust_decimal::Decimal;
use serde::{Deserialize, Deserializer, Serializer};
use std::str::FromStr;
#[derive(Deserialize)]
#[serde(untagged)]
enum WireNumber {
Text(String),
Number(serde_json::Number),
}
impl WireNumber {
fn into_decimal<E: serde::de::Error>(self) -> Result<Decimal, E> {
let text = match self {
WireNumber::Text(text) => text,
WireNumber::Number(number) => number.to_string(),
};
Decimal::from_str(text.trim()).map_err(|e| {
E::custom(format!(
"expected a decimal value, got something unparseable ({e})"
))
})
}
}
pub(crate) mod decimal {
use super::*;
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
where
D: Deserializer<'de>,
{
WireNumber::deserialize(deserializer)?.into_decimal()
}
pub(crate) fn serialize<S>(value: &Decimal, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
rust_decimal::serde::arbitrary_precision::serialize(value, serializer)
}
}
pub(crate) mod decimal_option {
use super::*;
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<Decimal>, D::Error>
where
D: Deserializer<'de>,
{
match Option::<WireNumber>::deserialize(deserializer)? {
Some(raw) => raw.into_decimal().map(Some),
None => Ok(None),
}
}
pub(crate) fn serialize<S>(value: &Option<Decimal>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
Some(value) => super::decimal::serialize(value, serializer),
None => serializer.serialize_none(),
}
}
}
pub(crate) mod decimal_string_option {
use super::*;
pub(crate) fn serialize<S>(value: &Option<Decimal>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
Some(value) => serializer.serialize_str(&value.to_string()),
None => serializer.serialize_none(),
}
}
}
pub(crate) mod decimal_option_nan {
use super::*;
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<Decimal>, D::Error>
where
D: Deserializer<'de>,
{
match Option::<WireNumber>::deserialize(deserializer)? {
Some(WireNumber::Text(text)) if text.trim().eq_ignore_ascii_case("nan") => Ok(None),
Some(raw) => raw.into_decimal().map(Some),
None => Ok(None),
}
}
pub(crate) fn serialize<S>(value: &Option<Decimal>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
super::decimal_option::serialize(value, serializer)
}
}
pub(crate) mod decimal_map {
use super::*;
use std::collections::HashMap;
pub(crate) fn deserialize<'de, D>(
deserializer: D,
) -> Result<Option<HashMap<String, Decimal>>, D::Error>
where
D: Deserializer<'de>,
{
let raw = match Option::<HashMap<String, WireNumber>>::deserialize(deserializer)? {
Some(raw) => raw,
None => return Ok(None),
};
let mut out = HashMap::with_capacity(raw.len());
for (symbol, number) in raw {
out.insert(symbol, number.into_decimal()?);
}
Ok(Some(out))
}
pub(crate) fn serialize<S>(
value: &Option<HashMap<String, Decimal>>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
Some(map) => serde::Serialize::serialize(map, serializer),
None => serializer.serialize_none(),
}
}
}
pub(crate) mod expiration_date_option {
use super::*;
use chrono::{DateTime, NaiveDate};
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDate>, D::Error>
where
D: Deserializer<'de>,
{
let Some(text) = Option::<String>::deserialize(deserializer)? else {
return Ok(None);
};
let text = text.trim();
if let Ok(date) = NaiveDate::parse_from_str(text, "%Y-%m-%d") {
return Ok(Some(date));
}
DateTime::parse_from_rfc3339(text)
.map(|moment| Some(moment.date_naive()))
.map_err(|e| {
serde::de::Error::custom(format!(
"expected an expiration as YYYY-MM-DD or RFC 3339 ({e})"
))
})
}
pub(crate) fn serialize<S>(value: &Option<NaiveDate>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
super::date_option::serialize(value, serializer)
}
}
pub(crate) mod loose_string_option {
use super::*;
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(match Option::<WireNumber>::deserialize(deserializer)? {
Some(WireNumber::Text(text)) => Some(text),
Some(WireNumber::Number(number)) => Some(number.to_string()),
None => None,
})
}
pub(crate) fn serialize<S>(value: &Option<String>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
Some(value) => serializer.serialize_str(value),
None => serializer.serialize_none(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Debug, Deserialize, serde::Serialize)]
struct Holder {
#[serde(with = "decimal")]
quantity: Decimal,
}
#[test]
fn both_wire_shapes_produce_the_same_value() {
let from_number: Holder =
serde_json::from_str(r#"{"quantity":1.5}"#).expect("a JSON number parses");
let from_text: Holder =
serde_json::from_str(r#"{"quantity":"1.5"}"#).expect("a quoted decimal parses");
assert_eq!(from_number.quantity, Decimal::from_str("1.5").unwrap());
assert_eq!(from_number.quantity, from_text.quantity);
}
#[test]
fn a_fractional_quantity_survives_intact() {
let holder: Holder = serde_json::from_str(r#"{"quantity":0.12345678901234567890}"#)
.expect("a long fraction parses");
assert_eq!(
holder.quantity.to_string(),
"0.12345678901234567890",
"digits were lost on the way in"
);
}
#[test]
fn whole_quantities_still_work_from_either_shape() {
for body in [r#"{"quantity":7}"#, r#"{"quantity":"7"}"#] {
let holder: Holder = serde_json::from_str(body).expect("whole numbers parse");
assert_eq!(holder.quantity, Decimal::from(7));
}
}
#[test]
fn a_value_that_is_not_a_number_is_an_error_not_a_zero() {
let error = serde_json::from_str::<Holder>(r#"{"quantity":"not a number"}"#)
.expect_err("garbage must not silently become zero");
assert!(
error.to_string().contains("decimal value"),
"the error should say what was expected: {error}"
);
}
#[test]
fn serialization_keeps_the_json_number_shape() {
let holder = Holder {
quantity: Decimal::from_str("2.5").unwrap(),
};
assert_eq!(
serde_json::to_string(&holder).expect("Holder serializes"),
r#"{"quantity":2.5}"#
);
}
#[test]
fn a_long_fraction_round_trips_through_serialization() {
let original = Decimal::from_str("0.12345678901234567890").unwrap();
let json = serde_json::to_string(&Holder { quantity: original }).expect("serializes");
let back: Holder = serde_json::from_str(&json).expect("parses back");
assert_eq!(
back.quantity, original,
"a digit was lost in the round trip"
);
}
}
pub(crate) mod datetime {
use super::*;
use chrono::{DateTime, FixedOffset};
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<FixedOffset>, D::Error>
where
D: Deserializer<'de>,
{
let text = String::deserialize(deserializer)?;
DateTime::parse_from_rfc3339(text.trim())
.map_err(|e| serde::de::Error::custom(format!("expected an RFC 3339 timestamp ({e})")))
}
pub(crate) fn serialize<S>(
value: &DateTime<FixedOffset>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&value.to_rfc3339())
}
}
pub(crate) mod datetime_option {
use super::*;
use chrono::{DateTime, FixedOffset};
pub(crate) fn deserialize<'de, D>(
deserializer: D,
) -> Result<Option<DateTime<FixedOffset>>, D::Error>
where
D: Deserializer<'de>,
{
match Option::<String>::deserialize(deserializer)? {
Some(text) => DateTime::parse_from_rfc3339(text.trim())
.map(Some)
.map_err(|e| {
serde::de::Error::custom(format!("expected an RFC 3339 timestamp ({e})"))
}),
None => Ok(None),
}
}
pub(crate) fn serialize<S>(
value: &Option<DateTime<FixedOffset>>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
Some(value) => serializer.serialize_str(&value.to_rfc3339()),
None => serializer.serialize_none(),
}
}
}
pub(crate) mod date {
use super::*;
use chrono::NaiveDate;
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
where
D: Deserializer<'de>,
{
let text = String::deserialize(deserializer)?;
NaiveDate::parse_from_str(text.trim(), "%Y-%m-%d")
.map_err(|e| serde::de::Error::custom(format!("expected a YYYY-MM-DD date ({e})")))
}
pub(crate) fn serialize<S>(value: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&value.format("%Y-%m-%d").to_string())
}
}
pub(crate) mod date_option {
use super::*;
use chrono::NaiveDate;
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDate>, D::Error>
where
D: Deserializer<'de>,
{
match Option::<String>::deserialize(deserializer)? {
Some(text) => NaiveDate::parse_from_str(text.trim(), "%Y-%m-%d")
.map(Some)
.map_err(|e| serde::de::Error::custom(format!("expected a YYYY-MM-DD date ({e})"))),
None => Ok(None),
}
}
pub(crate) fn serialize<S>(value: &Option<NaiveDate>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
Some(value) => serializer.serialize_str(&value.format("%Y-%m-%d").to_string()),
None => serializer.serialize_none(),
}
}
}
#[cfg(test)]
mod time_tests {
use super::*;
use chrono::{DateTime, Datelike, FixedOffset, NaiveDate};
use serde::Deserialize;
#[derive(Debug, Deserialize, serde::Serialize)]
struct Instant {
#[serde(with = "datetime")]
at: DateTime<FixedOffset>,
}
#[derive(Debug, Deserialize, serde::Serialize)]
struct Day {
#[serde(with = "date")]
on: NaiveDate,
}
#[test]
fn an_rfc_3339_instant_keeps_its_offset() {
let parsed: Instant =
serde_json::from_str(r#"{"at":"2025-09-19T13:30:00.000+00:00"}"#).expect("parses");
assert_eq!(parsed.at.date_naive().day(), 19);
assert_eq!(
parsed.at,
DateTime::parse_from_rfc3339("2025-09-19T13:30:00+00:00").unwrap()
);
}
#[test]
fn a_calendar_date_stays_naive() {
let parsed: Day = serde_json::from_str(r#"{"on":"2025-09-19"}"#).expect("parses");
assert_eq!(parsed.on, NaiveDate::from_ymd_opt(2025, 9, 19).unwrap());
assert_eq!(
serde_json::to_string(&parsed).expect("serializes"),
r#"{"on":"2025-09-19"}"#,
"the wire shape must round trip unchanged"
);
}
#[test]
fn the_two_formats_do_not_accept_each_other() {
serde_json::from_str::<Day>(r#"{"on":"2025-09-19T13:30:00.000+00:00"}"#)
.expect_err("a timestamp is not a calendar date");
serde_json::from_str::<Instant>(r#"{"at":"2025-09-19"}"#)
.expect_err("a calendar date is not an instant");
}
#[test]
fn an_unparseable_value_says_what_was_expected() {
let error = serde_json::from_str::<Day>(r#"{"on":"19/09/2025"}"#)
.expect_err("a non-ISO date must not parse");
assert!(error.to_string().contains("YYYY-MM-DD"), "{error}");
}
}
macro_rules! wire_enum {
(
$(#[$meta:meta])*
$name:ident { $($variant:ident => $wire:literal),+ $(,)? }
) => {
$(#[$meta])*
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum $name {
$(
#[doc = concat!("The venue's `", $wire, "`.")]
$variant,
)+
Unknown(String),
}
impl $name {
pub fn as_wire(&self) -> &str {
match self {
$( $name::$variant => $wire, )+
$name::Unknown(text) => text,
}
}
pub fn is_known(&self) -> bool {
!matches!(self, $name::Unknown(_))
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_wire())
}
}
impl From<String> for $name {
fn from(text: String) -> Self {
match text.trim() {
$( $wire => $name::$variant, )+
_ => $name::Unknown(text),
}
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(Self::from(String::deserialize(deserializer)?))
}
}
impl serde::Serialize for $name {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_wire())
}
}
};
}
pub(crate) use wire_enum;
pub(crate) fn tolerant_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: serde::de::DeserializeOwned,
{
let Some(value) = Option::<serde_json::Value>::deserialize(deserializer)? else {
return Ok(None);
};
match serde_json::from_value::<T>(value.clone()) {
Ok(decoded) => Ok(Some(decoded)),
Err(error) => {
tracing::debug!(
"field value {} is not one this crate models ({}); the field reads as absent \
and the rest of the record is kept",
value,
error
);
Ok(None)
}
}
}
pub(crate) fn names_an_account(name: &str) -> bool {
let name = name.replace('_', "-");
let lowered = name.to_ascii_lowercase();
let name = lowered
.strip_suffix("[]")
.or_else(|| lowered.strip_suffix("%5b%5d"))
.unwrap_or(&lowered);
name == "account-number"
|| name == "account-numbers"
|| name.ends_with("-account-number")
|| name.ends_with("-account-numbers")
}
pub(crate) fn redacted_render(value: &impl serde::Serialize) -> String {
use names_an_account as is_account_key;
fn redact(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
for (key, entry) in map.iter_mut() {
if is_account_key(key) && !entry.is_null() {
*entry = serde_json::Value::String("{account}".to_string());
} else {
redact(entry);
}
}
}
serde_json::Value::Array(items) => items.iter_mut().for_each(redact),
_ => {}
}
}
match serde_json::to_value(value) {
Ok(mut rendered) => {
redact(&mut rendered);
rendered.to_string()
}
Err(_) => "<unrenderable>".to_string(),
}
}
macro_rules! redacted_account_render {
($name:ident) => {
impl std::fmt::Debug for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
concat!(stringify!($name), " {}"),
$crate::types::wire::redacted_render(self)
)
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&$crate::types::wire::redacted_render(self))
}
}
};
}
pub(crate) use redacted_account_render;
wire_enum! {
ProductType {
Financial => "Financial",
Physical => "Physical",
}
}
wire_enum! {
ExpirationType {
Regular => "Regular",
Weekly => "Weekly",
Quarterly => "Quarterly",
EndOfMonth => "End-Of-Month",
}
}
wire_enum! {
SettlementType {
Am => "AM",
Pm => "PM",
}
}
wire_enum! {
ExerciseStyle {
American => "American",
European => "European",
}
}
wire_enum! {
Lendability {
EasyToBorrow => "Easy To Borrow",
LocateRequired => "Locate Required",
Preborrow => "Preborrow",
}
}
#[cfg(test)]
mod wire_enum_tests {
use super::*;
#[test]
fn a_known_value_maps_to_its_variant() {
let parsed: ExpirationType = serde_json::from_str(r#""End-Of-Month""#).expect("parses");
assert_eq!(parsed, ExpirationType::EndOfMonth);
assert!(parsed.is_known());
assert_eq!(parsed.to_string(), "End-Of-Month");
}
#[test]
fn an_unseen_value_is_preserved_rather_than_fatal() {
let parsed: ExpirationType = serde_json::from_str(r#""Fortnightly""#)
.expect("an unrecognised value must not fail the response");
assert_eq!(parsed, ExpirationType::Unknown("Fortnightly".to_string()));
assert!(!parsed.is_known(), "a caller can see this is new");
assert_eq!(parsed.as_wire(), "Fortnightly");
}
#[test]
fn every_value_round_trips_unchanged() {
macro_rules! round_trip {
($ty:ty, $($text:literal),+) => {
$(
let parsed: $ty = serde_json::from_str($text).expect("parses");
assert_eq!(
serde_json::to_string(&parsed).expect("serializes"),
$text,
concat!("the venue's own text must survive for ", stringify!($ty))
);
)+
};
}
round_trip!(
ExpirationType,
"\"Regular\"",
"\"Weekly\"",
"\"End-Of-Month\"",
"\"Fortnightly\""
);
round_trip!(SettlementType, "\"AM\"", "\"PM\"", "\"Overnight\"");
round_trip!(
ExerciseStyle,
"\"American\"",
"\"European\"",
"\"Bermudan\""
);
}
#[test]
fn whitespace_does_not_hide_a_known_value() {
let padded: SettlementType = serde_json::from_str(r#"" PM ""#).expect("parses");
assert_eq!(padded, SettlementType::Pm);
assert!(padded.is_known());
assert_eq!(
serde_json::to_string(&padded).expect("serializes"),
r#""PM""#,
"a known value normalises"
);
let unknown: SettlementType = serde_json::from_str(r#"" Overnight ""#).expect("parses");
assert_eq!(
unknown.as_wire(),
" Overnight ",
"an unknown value keeps exactly what arrived"
);
}
#[test]
fn matching_is_exhaustive_without_a_wildcard_on_known_values() {
let settlement = SettlementType::from("PM".to_string());
let described = match settlement {
SettlementType::Am => "morning",
SettlementType::Pm => "afternoon",
SettlementType::Unknown(_) => "unrecognised",
};
assert_eq!(described, "afternoon");
assert_eq!(
ExerciseStyle::from("American".to_string()),
ExerciseStyle::American
);
assert!(!ExerciseStyle::from("Bermudan".to_string()).is_known());
}
}