From 5b340491991e19bf51fc95115b1b43c1689e177f Mon Sep 17 00:00:00 2001 From: benbenbang Date: Thu, 1 Oct 2026 15:28:20 +0800 Subject: [PATCH 1/3] feat(crates): add empty-or-null nested-object codec This pull request adds a new codec for required wire keys that hold a nested object whose absent value the provider sends as an empty string or JSON null, together with its gen_go support (ADR 0033, #400). **Runtime codec:** * Added `empty_or_null_object` to `crates/libfmp/src/codecs.rs`, which decodes `""` and null to `None`, a JSON object to `Some(T)`, and rejects any other value with an error that never echoes the value. The key stays required and serialization uses serde's default. **Go generation:** * Taught `gen_go` to recognize the `empty_or_null_object::deserialize` path on an `Option` of a response struct, mapping it to a `jsontext.Value` shadow and emitting the raw-shadow decode that unmarshals the inner object when the value is neither null nor `""`. * Rejected misuse with a clear error when the codec is applied to anything other than an `Option` of a known response struct. **Tests:** * Added runtime tests covering empty, null, object, and invalid inputs (including that errors never leak the value), and a gen_go test verifying the rendered shadow, decode, and misuse diagnostics. --- crates/fmp-py-gen/src/bin/gen_go/models.rs | 17 +++ crates/fmp-py-gen/src/bin/gen_go/tests.rs | 57 ++++++++ crates/fmp-py-gen/src/bin/gen_go/types.rs | 19 +++ crates/libfmp/src/codecs.rs | 145 +++++++++++++++++++++ 4 files changed, 238 insertions(+) diff --git a/crates/fmp-py-gen/src/bin/gen_go/models.rs b/crates/fmp-py-gen/src/bin/gen_go/models.rs index 0401ac7..83e0677 100644 --- a/crates/fmp-py-gen/src/bin/gen_go/models.rs +++ b/crates/fmp-py-gen/src/bin/gen_go/models.rs @@ -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), @@ -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 @@ -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!( diff --git a/crates/fmp-py-gen/src/bin/gen_go/tests.rs b/crates/fmp-py-gen/src/bin/gen_go/tests.rs index f2e78e0..e5aaea9 100644 --- a/crates/fmp-py-gen/src/bin/gen_go/tests.rs +++ b/crates/fmp-py-gen/src/bin/gen_go/tests.rs @@ -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", + 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"), + ("many", "Option>"), + ] { + 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(); diff --git a/crates/fmp-py-gen/src/bin/gen_go/types.rs b/crates/fmp-py-gen/src/bin/gen_go/types.rs index ec27e23..cf0a34a 100644 --- a/crates/fmp-py-gen/src/bin/gen_go/types.rs +++ b/crates/fmp-py-gen/src/bin/gen_go/types.rs @@ -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 @@ -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"; @@ -96,6 +103,7 @@ impl GoField { | Codec::RequiredNumber | Codec::Count | Codec::EmptyOrNullString + | Codec::EmptyOrNullObject | Codec::NullTextString | Codec::EmptyDate | Codec::EmptyOrNullDate @@ -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()) } diff --git a/crates/libfmp/src/codecs.rs b/crates/libfmp/src/codecs.rs index f9b3191..b6d44e5 100644 --- a/crates/libfmp/src/codecs.rs +++ b/crates/libfmp/src/codecs.rs @@ -807,6 +807,96 @@ 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. +#[cfg_attr(not(test), allow(dead_code))] +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, D::Error> + where + D: Deserializer<'de>, + T: Deserialize<'de>, + { + deserializer.deserialize_any(ObjectVisitor(PhantomData)) + } + + struct ObjectVisitor(PhantomData); + + impl ObjectVisitor { + fn reject(self) -> Result, E> { + Err(E::custom(format_args!("expected {EXPECTED}"))) + } + } + + impl<'de, T: Deserialize<'de>> de::Visitor<'de> for ObjectVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(EXPECTED) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_some>( + self, + deserializer: D, + ) -> Result { + deserializer.deserialize_any(self) + } + + fn visit_str(self, value: &str) -> Result { + if value.is_empty() { + Ok(None) + } else { + self.reject() + } + } + + fn visit_bool(self, _: bool) -> Result { + self.reject() + } + + fn visit_i64(self, _: i64) -> Result { + self.reject() + } + + fn visit_u64(self, _: u64) -> Result { + self.reject() + } + + fn visit_f64(self, _: f64) -> Result { + self.reject() + } + + fn visit_seq>(self, _: A) -> Result { + self.reject() + } + + fn visit_map>(self, map: A) -> Result { + 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). /// @@ -994,6 +1084,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, + } + let decode = |text: &str| serde_json::from_str::(text); + let from_value = + |text: &str| serde_json::from_value::(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)] From fe540b914a7aaa78e13d40eac4093367f6e1318e Mon Sep 17 00:00:00 2001 From: benbenbang Date: Thu, 1 Oct 2026 15:52:11 +0800 Subject: [PATCH 2/3] fix(congressional): decode empty incomeRange as none The `incomeRange` member on `CongressionalMemberNetWorth` used `required_option`, which rejected the empty-string value that live `senate-net-worth` rows send. A probe of 4 members (993 rows) saw `""` on `incomeRange` 47 times, so decoding failed on real data. Switch the member to the new `codecs::empty_or_null_object` codec, which maps `""` and null to `None`, decodes a JSON object into the nested struct, and errors on any other shape without leaking the value. The Go SDK gains the matching guard so `""` decodes to `nil`. The member was already `Option`, so the public type is unchanged and this is not breaking. Drop the now-unused `allow(dead_code)` on the codec module, document the case in ADR 0033, and add Rust, Python, and Go parity tests covering the empty, null, object, and rejected-value paths. close #400 --- crates/fmp-py/tests/test_congressional_b.py | 21 +++++++ crates/libfmp/src/codecs.rs | 1 - crates/libfmp/src/responses/congressional.rs | 2 +- .../congressional_net_worth_endpoints.rs | 57 +++++++++++++++++++ docs/adr/0033-nullable-response-members.md | 15 ++++- sdk/go/congressional_models.go | 2 +- sdk/go/congressional_parity_test.go | 45 +++++++++++++++ 7 files changed, 139 insertions(+), 4 deletions(-) diff --git a/crates/fmp-py/tests/test_congressional_b.py b/crates/fmp-py/tests/test_congressional_b.py index 1a832ae..04acd79 100644 --- a/crates/fmp-py/tests/test_congressional_b.py +++ b/crates/fmp-py/tests/test_congressional_b.py @@ -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: diff --git a/crates/libfmp/src/codecs.rs b/crates/libfmp/src/codecs.rs index b6d44e5..0a3b03e 100644 --- a/crates/libfmp/src/codecs.rs +++ b/crates/libfmp/src/codecs.rs @@ -817,7 +817,6 @@ pub(crate) mod empty_or_null { /// `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. -#[cfg_attr(not(test), allow(dead_code))] pub(crate) mod empty_or_null_object { use super::*; diff --git a/crates/libfmp/src/responses/congressional.rs b/crates/libfmp/src/responses/congressional.rs index 07c0c53..f2f83b1 100644 --- a/crates/libfmp/src/responses/congressional.rs +++ b/crates/libfmp/src/responses/congressional.rs @@ -136,7 +136,7 @@ pub struct CongressionalMemberNetWorth { #[serde(deserialize_with = "required_option")] #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] pub value: Option, - #[serde(deserialize_with = "required_option")] + #[serde(deserialize_with = "crate::codecs::empty_or_null_object::deserialize")] pub income_range: Option, #[serde(deserialize_with = "required_option")] pub income: Option, diff --git a/crates/libfmp/tests/congressional_net_worth_endpoints.rs b/crates/libfmp/tests/congressional_net_worth_endpoints.rs index b0efb5c..ea093ac 100644 --- a/crates/libfmp/tests/congressional_net_worth_endpoints.rs +++ b/crates/libfmp/tests/congressional_net_worth_endpoints.rs @@ -463,3 +463,60 @@ async fn malformed_non_array_responses_keep_both_endpoint_identities() { assert_eq!(error.status_code(), Some(200)); } } + +#[tokio::test] +async fn income_range_decodes_empty_string_and_null_as_none_and_rejects_other_values() { + let row = serde_json::from_slice::(NET_WORTH).unwrap()[0].clone(); + let body = |income_range: serde_json::Value| -> &'static [u8] { + let mut row = row.clone(); + row["incomeRange"] = income_range; + serde_json::to_vec(&[row]).unwrap().leak() + }; + let executor = Arc::new(FixtureExecutor::new([ + json_fixture(body(serde_json::json!(""))), + json_fixture(body(serde_json::Value::Null)), + json_fixture(body(serde_json::json!({"min": 1001, "max": 15000}))), + json_fixture(body(serde_json::json!("SECRET-RANGE"))), + json_fixture(body(serde_json::json!(31337))), + json_fixture(body(serde_json::json!({"max": 15000}))), + ])); + let client = Client::builder() + .authentication(Authentication::fmp_header("secret")) + .executor(executor) + .build() + .unwrap(); + let member_id = || CongressionalMemberId::new("P000197").unwrap(); + + for _ in 0..2 { + let rows = client.congressional_net_worth(member_id()).await.unwrap(); + assert_eq!(rows[0].income_range, None); + assert_eq!( + serde_json::to_value(&rows[0]).unwrap()["incomeRange"], + serde_json::Value::Null + ); + } + let rows = client.congressional_net_worth(member_id()).await.unwrap(); + let range = rows[0].income_range.as_ref().unwrap(); + assert_eq!((range.min, range.max), (1001, Some(15000))); + + for (secret, path) in [ + ("SECRET-RANGE", "[0].incomeRange"), + ("31337", "[0].incomeRange"), + ("min", "[0].incomeRange.min"), + ] { + let error = client + .congressional_net_worth(member_id()) + .await + .unwrap_err(); + assert_eq!(error.category(), ErrorCategory::Decode); + assert_eq!(error.endpoint(), Some("senate-net-worth")); + assert_eq!(error.decode_path(), Some(path)); + if secret != "min" { + assert!(!error.message().contains(secret), "{}", error.message()); + } + } + + let mut missing = row.clone(); + missing.as_object_mut().unwrap().remove("incomeRange"); + assert!(serde_json::from_value::(missing).is_err()); +} diff --git a/docs/adr/0033-nullable-response-members.md b/docs/adr/0033-nullable-response-members.md index ae5c28a..cb9a3ae 100644 --- a/docs/adr/0033-nullable-response-members.md +++ b/docs/adr/0033-nullable-response-members.md @@ -29,6 +29,7 @@ evidence is now a second source, so both need a rule. | `""` on a date | `Option` through `codecs::empty_or_null_date` | `None` | `nil` | | `""` on a plain `String` | unchanged: stays `""` | `""` | `""` | | `"NULL"` text on a `NumericString` (`FundDisclosureSearchResult.entityOrgType`, #380) | `Option` through `codecs::null_text` | `None` | `nil` | +| `""` on a nested object (`CongressionalMemberNetWorth.incomeRange`, #400) | `Option` through `codecs::empty_or_null_object` | `None` | `nil` | | key absent from the object | plain `Option` with `skip_serializing_if = "Option::is_none"` (no `required_option`) | `None` | `*T` with `omitzero` | - Evidence decides: a member changes only when the audit (or a later @@ -45,6 +46,14 @@ evidence is now a second source, so both need a rule. used only where the evidence shows `"NULL"` standing in for an absent value (the live rows that send it on `entityOrgType` also send a null `address`); no other codec treats `"NULL"` as absent. +- `codecs::empty_or_null_object` accepts `""` and null as `None` and + decodes a JSON object as the nested struct; any other value (a non-empty + string, a number, an array) is an error that never includes the value. + First case, [#400](https://github.com/bitbrew-dev/libfmp/issues/400): a + live probe of 4 `senate-net-worth` members (993 rows) saw `""` on + `incomeRange` 47 times and never on `valueRange`, so only `incomeRange` + uses it. The member was already `Option`, so the public type is + unchanged and the fix is not breaking. - A member is optional-when-absent only when the audit saw its key missing, not merely null; every other member keeps a required key. First case, [#377](https://github.com/bitbrew-dev/libfmp/issues/377): @@ -71,7 +80,11 @@ on an `Option` of a string-backed type to `Codec::EmptyOrNullString`: the shadow member keeps the raw `jsontext.Value`, the key is required, and `""` or null decode to `nil`. `crate::codecs::null_text::deserialize` maps the same way to `Codec::NullTextString`, where the text `"NULL"` or null -decode to `nil`. It fails on any other shape rather than guessing. +decode to `nil`. `crate::codecs::empty_or_null_object::deserialize` on an +`Option` of a response struct maps to `Codec::EmptyOrNullObject`: the +shadow keeps the raw value, null or the exact `""` decode to `nil`, and +anything else unmarshals into the struct with failures reported through +`memberDecodeError`. It fails on any other shape rather than guessing. A plain `Option` without a codec is `Codec::Plain`: the key may be absent or null, and `skip_serializing_if = "Option::is_none"` adds `omitzero` so a re-encoded row omits it as serde does. diff --git a/sdk/go/congressional_models.go b/sdk/go/congressional_models.go index 9ea2f81..55478a5 100644 --- a/sdk/go/congressional_models.go +++ b/sdk/go/congressional_models.go @@ -494,7 +494,7 @@ func (m *CongressionalMemberNetWorth) UnmarshalJSONFrom(dec *jsontext.Decoder) e valueMember = &value } var incomeRange *CongressionalNetWorthRange - if shadow.IncomeRange.Kind() != 'n' { + if shadow.IncomeRange.Kind() != 'n' && string(shadow.IncomeRange) != "\"\"" { var value CongressionalNetWorthRange if err := json.Unmarshal(shadow.IncomeRange, &value); err != nil { return memberDecodeError("CongressionalMemberNetWorth", "incomeRange", shadow.IncomeRange, err) diff --git a/sdk/go/congressional_parity_test.go b/sdk/go/congressional_parity_test.go index 967bea7..8401685 100644 --- a/sdk/go/congressional_parity_test.go +++ b/sdk/go/congressional_parity_test.go @@ -330,3 +330,48 @@ func TestCongressionalNetWorthFractionalNullAndAbsentMembers(t *testing.T) { } } } + +func TestCongressionalNetWorthIncomeRangeDecodesEmptyAndNullAsNil(t *testing.T) { + t.Parallel() + const netWorth = "congress_senate_net_worth.json" + for _, raw := range []string{`""`, `null`} { + var rows []CongressionalMemberNetWorth + if err := json.Unmarshal(mutateFixtureMember(t, netWorth, "incomeRange", jsontext.Value(raw)), &rows); err != nil || + len(rows) != 1 || rows[0].IncomeRange != nil { + t.Fatalf("incomeRange %s = %+v, %v, want nil", raw, rows, err) + } + encoded, err := json.Marshal(rows[0]) + if err != nil || !strings.Contains(string(encoded), `"incomeRange":null`) { + t.Fatalf("re-encoded incomeRange %s = %s, %v", raw, encoded, err) + } + } + var rows []CongressionalMemberNetWorth + object := mutateFixtureMember(t, netWorth, "incomeRange", jsontext.Value(`{"min":1001,"max":15000}`)) + if err := json.Unmarshal(object, &rows); err != nil || len(rows) != 1 || + !reflect.DeepEqual(rows[0].IncomeRange, &CongressionalNetWorthRange{Min: 1001, Max: new(int64(15000))}) { + t.Fatalf("incomeRange object = %+v, %v", rows, err) + } + for _, tc := range []struct { + raw, path string + kind DecodeKind + }{ + {`"` + decodePathSentinel + `"`, "/0/incomeRange", DecodeKindWrongType}, + {`31337`, "/0/incomeRange", DecodeKindWrongType}, + {`{"max":15000}`, "/0/incomeRange/min", DecodeKindMissingMember}, + } { + body := mutateFixtureMember(t, netWorth, "incomeRange", jsontext.Value(tc.raw)) + err := json.Unmarshal(body, &rows) + if err == nil { + t.Fatalf("incomeRange %s decoded", tc.raw) + } + if path, kind := decodeLocation(body, err); path != tc.path || kind != tc.kind { + t.Fatalf("incomeRange %s: Path = %q, DecodeKind = %v, want %q %v", tc.raw, path, kind, tc.path, tc.kind) + } + if strings.Contains(err.Error(), decodePathSentinel) || strings.Contains(err.Error(), "31337") { + t.Fatalf("decode error leaked the member value: %q", err) + } + } + if err := json.Unmarshal(mutateFixtureMember(t, netWorth, "incomeRange", nil), &rows); err == nil { + t.Fatal("missing incomeRange decoded") + } +} From a1013aa90cfda9daf1e637313898ffb1e723464e Mon Sep 17 00:00:00 2001 From: benbenbang Date: Thu, 1 Oct 2026 15:55:14 +0800 Subject: [PATCH 3/3] docs(adr): document additional ADR 0033 codecs in Go SDK architecture Extend the ADR 0033 codec notes in the Go SDK architecture ADR to cover `crate::codecs::null_text::deserialize` (treats the text `"NULL"` and null as nil, #380) and `crate::codecs::empty_or_null_object::deserialize` on an `Option` of a response struct (`""` and null are nil, objects decode into the struct with a `*T` public field, #400). --- docs/adr/0030-go-sdk-architecture.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/adr/0030-go-sdk-architecture.md b/docs/adr/0030-go-sdk-architecture.md index a533644..6380be6 100644 --- a/docs/adr/0030-go-sdk-architecture.md +++ b/docs/adr/0030-go-sdk-architecture.md @@ -196,7 +196,11 @@ uses it, and the generator fails if one appears rather than widening it to The fifth, from ADR 0033, is `deserialize_with = "crate::codecs::empty_or_null::deserialize"` on an `Option` of a string-backed typed code: key required, `""` and null are -`nil`, public field `*string`. +`nil`, public field `*string`. ADR 0033 later adds +`crate::codecs::null_text::deserialize` (the text `"NULL"` and null are +`nil`, #380) and `crate::codecs::empty_or_null_object::deserialize` on an +`Option` of a response struct (`""` and null are `nil`, an object decodes +into the struct, public field `*T`, #400). ### Generator placement