Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions crates/fmp-py-gen/src/bin/gen_go/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ fn render_model(model: &ModelPlan, out: &mut String) {
| Codec::RequiredNumber
| Codec::Count
| Codec::EmptyOrNullString
| Codec::EmptyOrNullObject
| Codec::NullTextString
| Codec::EmptyDate
| Codec::EmptyOrNullDate => local_name(rust_name),
Expand Down Expand Up @@ -315,6 +316,7 @@ fn render_required_switch(model: &ModelPlan, out: &mut String) {
| Codec::RequiredNumber
| Codec::Count
| Codec::EmptyOrNullString
| Codec::EmptyOrNullObject
| Codec::NullTextString
| Codec::EmptyDate
| Codec::EmptyOrNullDate
Expand Down Expand Up @@ -379,6 +381,21 @@ fn render_codec_block(model: &str, field: &GoField, local: &str, out: &mut Strin
wire = field.wire
);
}
Codec::EmptyOrNullObject => {
let inner = field.public_ty.trim_start_matches('*');
let _ = writeln!(
out,
"\tvar {local} {ty}\n\tif shadow.{name}.Kind() != 'n' && string(shadow.{name}) != {empty:?} {{\n\
\t\tvar value {inner}\n\
\t\tif err := json.Unmarshal(shadow.{name}, &value); err != nil {{\n\
\t\t\treturn memberDecodeError({model:?}, {wire:?}, shadow.{name}, err)\n\
\t\t}}\n\t\t{local} = &value\n\t}}",
ty = field.public_ty,
name = field.name,
wire = field.wire,
empty = "\"\""
);
}
Codec::EmptyDate | Codec::EmptyOrNullDate => {
let allow_null = field.codec == Codec::EmptyOrNullDate;
let _ = writeln!(
Expand Down
57 changes: 57 additions & 0 deletions crates/fmp-py-gen/src/bin/gen_go/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,63 @@ fn empty_or_null_codec_decodes_empty_and_null_to_nil_through_the_raw_shadow() {
}
}

#[test]
fn empty_or_null_object_codec_decodes_empty_and_null_to_nil_through_the_raw_shadow() {
let aliases = BTreeMap::new();
let structs = vec![StructDef {
name: "NetWorthRange".to_string(),
..row(vec![field("min", "i64", FieldAttrs::default())])
}];
let table = TypeTable::new(&structs, &aliases);
let codec = "crate::codecs::empty_or_null_object::deserialize";
let def = row(vec![field(
"income_range",
"Option<NetWorthRange>",
deserialize_with(codec),
)]);
let mapped = table.go_field(&def, &def.fields[0]).expect("maps");
assert_eq!(
(
mapped.public_ty.as_str(),
mapped.shadow_ty.as_str(),
mapped.codec,
mapped.required_key(),
),
(
"*NetWorthRange",
"jsontext.Value",
Codec::EmptyOrNullObject,
true
)
);
let models = plan_models("test", &[&def], &table).expect("plans");
let rendered = render_models("test", &models);
for expected in [
"IncomeRange *NetWorthRange `json:\"incomeRange\"`",
"IncomeRange jsontext.Value `json:\"incomeRange\"`",
"case len(shadow.IncomeRange) == 0:",
"var incomeRange *NetWorthRange\n\tif shadow.IncomeRange.Kind() != 'n' && \
string(shadow.IncomeRange) != \"\\\"\\\"\" {\n\t\tvar value NetWorthRange\n",
"return memberDecodeError(\"Row\", \"incomeRange\", shadow.IncomeRange, err)",
"IncomeRange: incomeRange,",
] {
assert!(
rendered.contains(expected),
"missing {expected:?} in\n{rendered}"
);
}

for (name, ty) in [
("bare", "NetWorthRange"),
("text", "Option<String>"),
("many", "Option<Vec<NetWorthRange>>"),
] {
let def = row(vec![field(name, ty, deserialize_with(codec))]);
let error = table.go_field(&def, &def.fields[0]).expect_err(name);
assert!(error.contains("empty_or_null_object"), "{name}: {error}");
}
}

#[test]
fn null_text_codec_decodes_the_null_text_and_null_to_nil_through_the_raw_shadow() {
let aliases = BTreeMap::new();
Expand Down
19 changes: 19 additions & 0 deletions crates/fmp-py-gen/src/bin/gen_go/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ pub(crate) enum Codec {
/// `Option` of a string-backed type: the key is required, and `""` and
/// null are both absent (ADR 0033). The shadow keeps the raw value.
EmptyOrNullString,
/// `deserialize_with = "crate::codecs::empty_or_null_object::deserialize"`
/// on an `Option` of a response struct: the key is required, and `""` and
/// null are both absent (ADR 0033, #400). The shadow keeps the raw value.
EmptyOrNullObject,
/// `deserialize_with = "crate::codecs::null_text::deserialize"` on an
/// `Option` of a string-backed type: the key is required, and the text
/// `"NULL"` and null are both absent (ADR 0033). The shadow keeps the raw
Expand All @@ -59,6 +63,9 @@ const COUNT_CODEC: &str = "crate::codecs::count::deserialize";
/// The `deserialize_with` path of the Rust empty-or-null typed-code codec.
const EMPTY_OR_NULL_CODEC: &str = "crate::codecs::empty_or_null::deserialize";

/// The `deserialize_with` path of the Rust empty-or-null nested-object codec.
const EMPTY_OR_NULL_OBJECT_CODEC: &str = "crate::codecs::empty_or_null_object::deserialize";

/// The `deserialize_with` path of the Rust `"NULL"`-text codec.
const NULL_TEXT_CODEC: &str = "crate::codecs::null_text::deserialize";

Expand Down Expand Up @@ -96,6 +103,7 @@ impl GoField {
| Codec::RequiredNumber
| Codec::Count
| Codec::EmptyOrNullString
| Codec::EmptyOrNullObject
| Codec::NullTextString
| Codec::EmptyDate
| Codec::EmptyOrNullDate
Expand Down Expand Up @@ -216,6 +224,17 @@ impl<'a> TypeTable<'a> {
"the empty_or_null codec needs an Option of a string-backed type".to_string(),
));
}
(Some(EMPTY_OR_NULL_OBJECT_CODEC), BaseKind::Scalar, [Wrap::Option])
if self.structs.contains(ident.as_str()) =>
{
(Codec::EmptyOrNullObject, "jsontext.Value".to_string())
}
(Some(EMPTY_OR_NULL_OBJECT_CODEC), _, _) => {
return Err(fail(
"the empty_or_null_object codec needs an Option of a response struct"
.to_string(),
));
}
(Some(NULL_TEXT_CODEC), BaseKind::Scalar, [Wrap::Option]) if base.go == "string" => {
(Codec::NullTextString, "jsontext.Value".to_string())
}
Expand Down
21 changes: 21 additions & 0 deletions crates/fmp-py/tests/test_congressional_b.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,27 @@ def test_net_worth_decodes_fractional_value_and_null_members(client: Any, fixtur
assert rows[1].value_range.max is None


def test_net_worth_income_range_decodes_empty_and_null_as_none(
client: Any, fixture_server: FixtureServer, errors: SimpleNamespace
) -> None:
"""An ``incomeRange`` of ``""`` or null is ``None``; an object decodes; any other value is a decode error."""
body = load_fixture("congress_senate_net_worth.json")
rows = [dict(body[0], incomeRange=value) for value in ("", None, {"min": 1001, "max": 15000})]
fixture_server.route("/senate-net-worth", rows)
decoded = client.congressional.net_worth(MEMBER_ID)

assert decoded[0].income_range is None
assert decoded[1].income_range is None
assert isinstance(decoded[2].income_range, CongressionalNetWorthRange)
assert (decoded[2].income_range.min, decoded[2].income_range.max) == (1001, 15000)

fixture_server.route("/senate-net-worth", [dict(body[0], incomeRange="1001-15000")])
with pytest.raises(errors.FmpDecodeError) as raised:
client.congressional.net_worth(MEMBER_ID)
assert raised.value.endpoint == "senate-net-worth"
assert raised.value.decode_path == "[0].incomeRange"


def test_net_worth_aggregated_decodes_fractional_amounts_and_absent_members(
client: Any, fixture_server: FixtureServer
) -> None:
Expand Down
144 changes: 144 additions & 0 deletions crates/libfmp/src/codecs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,95 @@ pub(crate) mod empty_or_null {
}
}

/// Deserializer for a required wire key holding a nested object whose absent
/// value the provider sends as `""` or JSON null (ADR 0033, #400).
///
/// Both spellings decode to `None`; a JSON object decodes as `T`, and any
/// other value (a non-empty string, a number, an array) is a decode error
/// whose message never includes the value. The key stays required, as with
/// `required_option`: do not add `default` at call sites. Apply with
/// `deserialize_with = "crate::codecs::empty_or_null_object::deserialize"`
/// (the exact path gen_go matches); serialization is serde's default, so
/// `None` re-encodes as null.
pub(crate) mod empty_or_null_object {
use super::*;

use std::marker::PhantomData;

const EXPECTED: &str = "a JSON object, an empty string or null";

pub(crate) fn deserialize<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
deserializer.deserialize_any(ObjectVisitor(PhantomData))
}

struct ObjectVisitor<T>(PhantomData<T>);

impl<T> ObjectVisitor<T> {
fn reject<E: de::Error>(self) -> Result<Option<T>, E> {
Err(E::custom(format_args!("expected {EXPECTED}")))
}
}

impl<'de, T: Deserialize<'de>> de::Visitor<'de> for ObjectVisitor<T> {
type Value = Option<T>;

fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(EXPECTED)
}

fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}

fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}

fn visit_some<D: Deserializer<'de>>(
self,
deserializer: D,
) -> Result<Self::Value, D::Error> {
deserializer.deserialize_any(self)
}

fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
if value.is_empty() {
Ok(None)
} else {
self.reject()
}
}

fn visit_bool<E: de::Error>(self, _: bool) -> Result<Self::Value, E> {
self.reject()
}

fn visit_i64<E: de::Error>(self, _: i64) -> Result<Self::Value, E> {
self.reject()
}

fn visit_u64<E: de::Error>(self, _: u64) -> Result<Self::Value, E> {
self.reject()
}

fn visit_f64<E: de::Error>(self, _: f64) -> Result<Self::Value, E> {
self.reject()
}

fn visit_seq<A: de::SeqAccess<'de>>(self, _: A) -> Result<Self::Value, A::Error> {
self.reject()
}

fn visit_map<A: de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
T::deserialize(de::value::MapAccessDeserializer::new(map)).map(Some)
}
}
}

/// Deserializer for a required wire key holding a [`NumericString`] whose
/// absent value the provider sends as the text `"NULL"` (ADR 0033, #380).
///
Expand Down Expand Up @@ -994,6 +1083,61 @@ mod tests {
);
}

#[test]
fn empty_or_null_object_maps_empty_and_null_to_none_and_decodes_objects() {
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Range {
min: i64,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Row {
#[serde(deserialize_with = "super::empty_or_null_object::deserialize")]
range: Option<Range>,
}
let decode = |text: &str| serde_json::from_str::<Row>(text);
let from_value =
|text: &str| serde_json::from_value::<Row>(serde_json::from_str(text).unwrap());

for text in [r#"{"range":""}"#, r#"{"range":null}"#] {
assert_eq!(decode(text).unwrap(), Row { range: None });
assert_eq!(from_value(text).unwrap(), Row { range: None });
}
let row = decode(r#"{"range":{"min":-10}}"#).unwrap();
assert_eq!(
row,
Row {
range: Some(Range { min: -10 })
}
);
assert_eq!(from_value(r#"{"range":{"min":-10}}"#).unwrap(), row);
assert_eq!(
serde_json::to_string(&row).unwrap(),
r#"{"range":{"min":-10}}"#
);
assert_eq!(
serde_json::to_string(&Row { range: None }).unwrap(),
r#"{"range":null}"#
);

for (text, secret) in [
(r#"{"range":"SECRET"}"#, "SECRET"),
(r#"{"range":" "}"#, "\" \""),
(r#"{"range":31337}"#, "31337"),
(r#"{"range":["SECRET"]}"#, "SECRET"),
(r#"{"range":true}"#, "true"),
] {
let error = decode(text).unwrap_err().to_string();
assert!(from_value(text).is_err(), "from_value {text}");
assert!(
!error.contains(secret),
"the error must not echo the value: {error}"
);
}
assert!(decode(r#"{"range":{}}"#).is_err());
let missing = decode("{}").unwrap_err().to_string();
assert!(missing.contains("missing field `range`"), "{missing}");
}

#[test]
fn null_text_maps_the_null_text_and_null_to_none_and_parses_the_rest() {
#[derive(Debug, PartialEq, Serialize, Deserialize)]
Expand Down
2 changes: 1 addition & 1 deletion crates/libfmp/src/responses/congressional.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ pub struct CongressionalMemberNetWorth {
#[serde(deserialize_with = "required_option")]
#[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")]
pub value: Option<MarketValue>,
#[serde(deserialize_with = "required_option")]
#[serde(deserialize_with = "crate::codecs::empty_or_null_object::deserialize")]
pub income_range: Option<CongressionalNetWorthRange>,
#[serde(deserialize_with = "required_option")]
pub income: Option<DynamicJson>,
Expand Down
Loading
Loading