From 3155e8d3beb6ca2262454c1dc14faa9ea83fcc39 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 21:26:06 +0800 Subject: [PATCH 1/3] feat(responses): make omittable news, market hours, esg and congressional members optional Members that FMP can send as null are now optional so decoding no longer fails on absent values. Consumers must handle None for these members. **Optional member changes:** * `NewsArticle.image` and `NewsArticle.site` are now optional. * `ExchangeHoliday.is_closed` is now optional. * `EsgDisclosure.company_name` and `EsgDisclosure.form_type` are now optional. * `EsgRating.company_name` is now optional. * `CongressionalMemberProfile.image` is now optional. **Decode behaviour:** * These members are still required to be present, but a null value decodes to None/nil rather than raising a decode error. * The Rust responses use `required_option` deserializers; the Go SDK uses `jsontext.Value` shadows that reject missing members and map null to nil. **Bindings and tests:** * Updated the Python `.pyi` stubs and pyo3 models to expose the optional types, and adjusted the typecheck contract. * Added Rust, Python, and Go parity tests covering null-decodes-to-none while a missing member still fails. BREAKING CHANGE: NewsArticle, ExchangeHoliday, EsgDisclosure, EsgRating and CongressionalMemberProfile members FMP can omit are now optional (Option / pointer / Optional); handle None. --- .../fmp/_native/congressional/__init__.pyi | 4 +- .../python/fmp/_native/esg/__init__.pyi | 12 +-- .../fmp/_native/market_hours/__init__.pyi | 4 +- .../python/fmp/_native/news/__init__.pyi | 8 +- crates/fmp-py/src/models/congressional.rs | 4 +- crates/fmp-py/src/models/esg.rs | 14 +-- crates/fmp-py/src/models/market_hours.rs | 4 +- crates/fmp-py/src/models/news.rs | 8 +- crates/fmp-py/tests/test_congressional_b.py | 10 +++ crates/fmp-py/tests/test_esg.py | 22 +++++ crates/fmp-py/tests/test_market_hours.py | 10 +++ crates/fmp-py/tests/test_news_a.py | 12 +++ crates/fmp-py/tests/typecheck_contract.py | 2 +- crates/libfmp/src/responses/congressional.rs | 3 +- crates/libfmp/src/responses/esg.rs | 19 +++- crates/libfmp/src/responses/market_hours.rs | 3 +- crates/libfmp/src/responses/news.rs | 6 +- .../tests/congressional_member_endpoints.rs | 11 ++- crates/libfmp/tests/esg_responses.rs | 22 +++-- crates/libfmp/tests/market_hours_responses.rs | 15 ++++ crates/libfmp/tests/news_responses.rs | 15 +++- sdk/go/congressional_models.go | 34 ++++--- sdk/go/congressional_parity_test.go | 34 ++++++- sdk/go/esg_models.go | 90 ++++++++++++------- sdk/go/esg_parity_test.go | 64 ++++++++++++- sdk/go/market_hours_models.go | 16 +++- sdk/go/market_hours_parity_test.go | 33 ++++++- sdk/go/news_models.go | 32 +++++-- sdk/go/news_parity_test.go | 34 ++++++- 29 files changed, 429 insertions(+), 116 deletions(-) diff --git a/crates/fmp-py/python/fmp/_native/congressional/__init__.pyi b/crates/fmp-py/python/fmp/_native/congressional/__init__.pyi index 00f5b51e..2e56c9eb 100644 --- a/crates/fmp-py/python/fmp/_native/congressional/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/congressional/__init__.pyi @@ -281,7 +281,7 @@ class CongressionalMemberProfile: @property def latest_position(self) -> builtins.str: ... @property - def image(self) -> builtins.str: ... + def image(self) -> typing.Optional[builtins.str]: ... @property def active(self) -> builtins.bool: ... @property @@ -297,7 +297,7 @@ class CongressionalMemberProfile: latest_party: builtins.str, latest_state: builtins.str, latest_position: builtins.str, - image: builtins.str, + image: typing.Optional[builtins.str], active: builtins.bool, years_active: builtins.float, ) -> CongressionalMemberProfile: ... diff --git a/crates/fmp-py/python/fmp/_native/esg/__init__.pyi b/crates/fmp-py/python/fmp/_native/esg/__init__.pyi index 01f7493f..c78237ae 100644 --- a/crates/fmp-py/python/fmp/_native/esg/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/esg/__init__.pyi @@ -82,9 +82,9 @@ class EsgDisclosure: @property def cik(self) -> builtins.str: ... @property - def company_name(self) -> builtins.str: ... + def company_name(self) -> typing.Optional[builtins.str]: ... @property - def form_type(self) -> builtins.str: ... + def form_type(self) -> typing.Optional[builtins.str]: ... @property def environmental_score(self) -> builtins.float: ... @property @@ -103,8 +103,8 @@ class EsgDisclosure: accepted_date: datetime.date, symbol: builtins.str, cik: builtins.str, - company_name: builtins.str, - form_type: builtins.str, + company_name: typing.Optional[builtins.str], + form_type: typing.Optional[builtins.str], environmental_score: builtins.float, social_score: builtins.float, governance_score: builtins.float, @@ -162,7 +162,7 @@ class EsgRating: @property def cik(self) -> builtins.str: ... @property - def company_name(self) -> builtins.str: ... + def company_name(self) -> typing.Optional[builtins.str]: ... @property def industry(self) -> builtins.str: ... @property @@ -177,7 +177,7 @@ class EsgRating: *, symbol: builtins.str, cik: builtins.str, - company_name: builtins.str, + company_name: typing.Optional[builtins.str], industry: builtins.str, fiscal_year: builtins.int, esg_risk_rating: builtins.str, diff --git a/crates/fmp-py/python/fmp/_native/market_hours/__init__.pyi b/crates/fmp-py/python/fmp/_native/market_hours/__init__.pyi index 903ee17f..91db6683 100644 --- a/crates/fmp-py/python/fmp/_native/market_hours/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/market_hours/__init__.pyi @@ -30,7 +30,7 @@ class ExchangeHoliday: @property def name(self) -> builtins.str: ... @property - def is_closed(self) -> builtins.bool: ... + def is_closed(self) -> typing.Optional[builtins.bool]: ... @property def adj_open_time(self) -> typing.Any: ... @property @@ -42,7 +42,7 @@ class ExchangeHoliday: exchange: builtins.str, date: datetime.date, name: builtins.str, - is_closed: builtins.bool, + is_closed: typing.Optional[builtins.bool], adj_open_time: typing.Optional[builtins.str], adj_close_time: typing.Optional[builtins.str], ) -> ExchangeHoliday: ... diff --git a/crates/fmp-py/python/fmp/_native/news/__init__.pyi b/crates/fmp-py/python/fmp/_native/news/__init__.pyi index c237fe3c..bf9fb4fd 100644 --- a/crates/fmp-py/python/fmp/_native/news/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/news/__init__.pyi @@ -86,9 +86,9 @@ class NewsArticle: @property def title(self) -> builtins.str: ... @property - def image(self) -> builtins.str: ... + def image(self) -> typing.Optional[builtins.str]: ... @property - def site(self) -> builtins.str: ... + def site(self) -> typing.Optional[builtins.str]: ... @property def text(self) -> builtins.str: ... @property @@ -101,8 +101,8 @@ class NewsArticle: published_date: datetime.datetime, publisher: builtins.str, title: builtins.str, - image: builtins.str, - site: builtins.str, + image: typing.Optional[builtins.str], + site: typing.Optional[builtins.str], text: builtins.str, url: builtins.str, ) -> NewsArticle: ... diff --git a/crates/fmp-py/src/models/congressional.rs b/crates/fmp-py/src/models/congressional.rs index 951b01df..96a30317 100644 --- a/crates/fmp-py/src/models/congressional.rs +++ b/crates/fmp-py/src/models/congressional.rs @@ -329,7 +329,7 @@ pub(crate) struct CongressionalMemberProfile { #[pyo3(get)] pub latest_position: String, #[pyo3(get)] - pub image: String, + pub image: Option, #[pyo3(get)] pub active: bool, #[pyo3(get)] @@ -351,7 +351,7 @@ impl CongressionalMemberProfile { latest_party: String, latest_state: String, latest_position: String, - image: String, + image: Option, active: bool, years_active: f64, ) -> Self { diff --git a/crates/fmp-py/src/models/esg.rs b/crates/fmp-py/src/models/esg.rs index 3f664ed9..fbfa7b04 100644 --- a/crates/fmp-py/src/models/esg.rs +++ b/crates/fmp-py/src/models/esg.rs @@ -18,9 +18,9 @@ pub(crate) struct EsgDisclosure { #[pyo3(get)] pub cik: String, #[pyo3(get)] - pub company_name: String, + pub company_name: Option, #[pyo3(get)] - pub form_type: String, + pub form_type: Option, #[pyo3(get)] pub environmental_score: f64, #[pyo3(get)] @@ -45,8 +45,8 @@ impl EsgDisclosure { accepted_date: ::chrono::NaiveDate, symbol: String, cik: String, - company_name: String, - form_type: String, + company_name: Option, + form_type: Option, environmental_score: f64, social_score: f64, governance_score: f64, @@ -222,7 +222,7 @@ impl From for EsgDisclosure { symbol: value.symbol.into_inner(), cik: value.cik.into_inner(), company_name: value.company_name, - form_type: value.form_type.into_inner(), + form_type: value.form_type.map(|value| value.into_inner()), environmental_score: value.environmental_score, social_score: value.social_score, governance_score: value.governance_score, @@ -247,7 +247,7 @@ pub(crate) struct EsgRating { #[pyo3(get)] pub cik: String, #[pyo3(get)] - pub company_name: String, + pub company_name: Option, #[pyo3(get)] pub industry: String, #[pyo3(get)] @@ -268,7 +268,7 @@ impl EsgRating { fn new( symbol: String, cik: String, - company_name: String, + company_name: Option, industry: String, fiscal_year: u32, esg_risk_rating: String, diff --git a/crates/fmp-py/src/models/market_hours.rs b/crates/fmp-py/src/models/market_hours.rs index d7ed4880..7d6f128f 100644 --- a/crates/fmp-py/src/models/market_hours.rs +++ b/crates/fmp-py/src/models/market_hours.rs @@ -177,7 +177,7 @@ pub(crate) struct ExchangeHoliday { #[pyo3(get)] pub name: String, #[pyo3(get)] - pub is_closed: bool, + pub is_closed: Option, adj_open_time: Option<::serde_json::Value>, adj_close_time: Option<::serde_json::Value>, } @@ -193,7 +193,7 @@ impl ExchangeHoliday { exchange: String, date: ::chrono::NaiveDate, name: String, - is_closed: bool, + is_closed: Option, adj_open_time: Option, adj_close_time: Option, ) -> PyResult { diff --git a/crates/fmp-py/src/models/news.rs b/crates/fmp-py/src/models/news.rs index ca97a4b2..a750f2d3 100644 --- a/crates/fmp-py/src/models/news.rs +++ b/crates/fmp-py/src/models/news.rs @@ -178,9 +178,9 @@ pub(crate) struct NewsArticle { #[pyo3(get)] pub title: String, #[pyo3(get)] - pub image: String, + pub image: Option, #[pyo3(get)] - pub site: String, + pub site: Option, #[pyo3(get)] pub text: String, #[pyo3(get)] @@ -199,8 +199,8 @@ impl NewsArticle { published_date: ::chrono::NaiveDateTime, publisher: String, title: String, - image: String, - site: String, + image: Option, + site: Option, text: String, url: String, ) -> Self { diff --git a/crates/fmp-py/tests/test_congressional_b.py b/crates/fmp-py/tests/test_congressional_b.py index a10d29c9..e477f1de 100644 --- a/crates/fmp-py/tests/test_congressional_b.py +++ b/crates/fmp-py/tests/test_congressional_b.py @@ -275,3 +275,13 @@ def test_decode_failure_is_structured(client: Any, fixture_server: FixtureServer client.congressional.net_worth_aggregated(MEMBER_ID) assert raised.value.endpoint == "senate-net-worth-aggregated" assert len(fixture_server.requests) == 1 + + +def test_profiles_decode_null_image_to_none(client: Any, fixture_server: FixtureServer) -> None: + """A member profile with a null ``image`` decodes it to ``None``.""" + body = load_fixture("congress_senate_profile.json") + body[0]["image"] = None + fixture_server.route("/senate-profile", body) + rows = client.congressional.profiles() + + assert rows[0].image is None diff --git a/crates/fmp-py/tests/test_esg.py b/crates/fmp-py/tests/test_esg.py index d1859765..c5ac0622 100644 --- a/crates/fmp-py/tests/test_esg.py +++ b/crates/fmp-py/tests/test_esg.py @@ -224,3 +224,25 @@ def test_decode_error_names_the_disclosures_endpoint( with pytest.raises(errors.FmpDecodeError) as raised: client.esg.disclosures("AAPL") assert raised.value.endpoint == "esg-disclosures" + + +def test_disclosures_decode_null_company_name_and_form_type_to_none(client: Any, fixture_server: FixtureServer) -> None: + """A null ``companyName`` or ``formType`` decodes to ``None``.""" + body = load_fixture("esg_disclosures.json") + body[0]["companyName"] = None + body[0]["formType"] = None + fixture_server.route("/esg-disclosures", body) + rows = client.esg.disclosures("AAPL") + + assert rows[0].company_name is None + assert rows[0].form_type is None + + +def test_ratings_decode_null_company_name_to_none(client: Any, fixture_server: FixtureServer) -> None: + """A null ``companyName`` decodes to ``None``.""" + body = load_fixture("esg_ratings.json") + body[0]["companyName"] = None + fixture_server.route("/esg-ratings", body) + rows = client.esg.ratings("AAPL") + + assert rows[0].company_name is None diff --git a/crates/fmp-py/tests/test_market_hours.py b/crates/fmp-py/tests/test_market_hours.py index ecc20edc..39621ff6 100644 --- a/crates/fmp-py/tests/test_market_hours.py +++ b/crates/fmp-py/tests/test_market_hours.py @@ -271,3 +271,13 @@ def test_decode_error_names_the_exchange_endpoint( with pytest.raises(errors.FmpDecodeError) as raised: client.market_hours.exchange("NASDAQ") assert raised.value.endpoint == "exchange-market-hours" + + +def test_holidays_decode_null_is_closed_to_none(client: Any, fixture_server: FixtureServer) -> None: + """A null ``isClosed`` on an early-close day decodes to ``None``.""" + body = load_fixture("holidays_by_exchange.json") + body[0]["isClosed"] = None + fixture_server.route("/holidays-by-exchange", body) + rows = client.market_hours.holidays("NASDAQ") + + assert rows[0].is_closed is None diff --git a/crates/fmp-py/tests/test_news_a.py b/crates/fmp-py/tests/test_news_a.py index 7c45f650..6b7fd9f4 100644 --- a/crates/fmp-py/tests/test_news_a.py +++ b/crates/fmp-py/tests/test_news_a.py @@ -216,3 +216,15 @@ def test_latest_feeds_decode_an_empty_array(client: Any, fixture_server: Fixture "/news/general-latest", "/news/stock-latest?limit=0", ] + + +def test_null_image_and_site_decode_to_none(client: Any, fixture_server: FixtureServer) -> None: + """A null ``image`` or ``site`` decodes to ``None``.""" + body = load_fixture("latest_crypto_news.json") + body[0]["image"] = None + body[0]["site"] = None + fixture_server.route("/news/crypto-latest", body) + rows = client.news.latest_crypto_news() + + assert rows[0].image is None + assert rows[0].site is None diff --git a/crates/fmp-py/tests/typecheck_contract.py b/crates/fmp-py/tests/typecheck_contract.py index 1dab68c0..73bdc28a 100644 --- a/crates/fmp-py/tests/typecheck_contract.py +++ b/crates/fmp-py/tests/typecheck_contract.py @@ -549,7 +549,7 @@ def check_market_hours_contract(client: FmpClient) -> None: "NASDAQ", from_=datetime.date(2025, 4, 27), to="2026-04-27" ) holiday_date: datetime.date = holidays[0].date - is_closed: bool = holidays[0].is_closed + is_closed: bool | None = holidays[0].is_closed adj_open_time: Any = holidays[0].adj_open_time everywhere: list[ExchangeMarketHours] = client.market_hours.all_exchanges() timezone: str = everywhere[0].timezone diff --git a/crates/libfmp/src/responses/congressional.rs b/crates/libfmp/src/responses/congressional.rs index 12d17391..8a193df3 100644 --- a/crates/libfmp/src/responses/congressional.rs +++ b/crates/libfmp/src/responses/congressional.rs @@ -65,7 +65,8 @@ pub struct CongressionalMemberProfile { pub latest_party: String, pub latest_state: String, pub latest_position: String, - pub image: String, + #[serde(deserialize_with = "required_option")] + pub image: Option, pub active: bool, pub years_active: f64, } diff --git a/crates/libfmp/src/responses/esg.rs b/crates/libfmp/src/responses/esg.rs index 39c88df8..a4083327 100644 --- a/crates/libfmp/src/responses/esg.rs +++ b/crates/libfmp/src/responses/esg.rs @@ -2,10 +2,18 @@ //! //! The Python binding exposes these models under `fmp.esg`. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::types::{CalendarYear, Cik, Date, Industry, Sector, Ticker}; +fn required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + /// One company ESG disclosure filing and its component scores. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[non_exhaustive] @@ -15,8 +23,10 @@ pub struct EsgDisclosure { pub accepted_date: Date, pub symbol: Ticker, pub cik: Cik, - pub company_name: String, - pub form_type: crate::types::FormType, + #[serde(deserialize_with = "required_option")] + pub company_name: Option, + #[serde(deserialize_with = "required_option")] + pub form_type: Option, pub environmental_score: f64, pub social_score: f64, pub governance_score: f64, @@ -32,7 +42,8 @@ pub struct EsgDisclosure { pub struct EsgRating { pub symbol: Ticker, pub cik: Cik, - pub company_name: String, + #[serde(deserialize_with = "required_option")] + pub company_name: Option, pub industry: Industry, pub fiscal_year: CalendarYear, #[serde(rename = "ESGRiskRating")] diff --git a/crates/libfmp/src/responses/market_hours.rs b/crates/libfmp/src/responses/market_hours.rs index 4807870b..53dc4441 100644 --- a/crates/libfmp/src/responses/market_hours.rs +++ b/crates/libfmp/src/responses/market_hours.rs @@ -40,7 +40,8 @@ pub struct ExchangeHoliday { pub exchange: ExchangeCode, pub date: Date, pub name: String, - pub is_closed: bool, + #[serde(deserialize_with = "required_option")] + pub is_closed: Option, #[serde(deserialize_with = "required_option")] pub adj_open_time: Option, #[serde(deserialize_with = "required_option")] diff --git a/crates/libfmp/src/responses/news.rs b/crates/libfmp/src/responses/news.rs index d27d569c..b8cf09bc 100644 --- a/crates/libfmp/src/responses/news.rs +++ b/crates/libfmp/src/responses/news.rs @@ -37,8 +37,10 @@ pub struct NewsArticle { pub published_date: ApiDateTime, pub publisher: String, pub title: String, - pub image: String, - pub site: String, + #[serde(deserialize_with = "required_option")] + pub image: Option, + #[serde(deserialize_with = "required_option")] + pub site: Option, pub text: String, pub url: String, } diff --git a/crates/libfmp/tests/congressional_member_endpoints.rs b/crates/libfmp/tests/congressional_member_endpoints.rs index 0a9b3c6d..af38afff 100644 --- a/crates/libfmp/tests/congressional_member_endpoints.rs +++ b/crates/libfmp/tests/congressional_member_endpoints.rs @@ -198,7 +198,7 @@ async fn omitted_options_stay_omitted_and_unknown_response_fields_are_tolerated( } #[test] -fn documented_fields_have_strict_types_and_only_position_end_date_is_nullable() { +fn documented_fields_have_strict_types_and_only_image_and_end_date_are_nullable() { let profile = serde_json::from_slice::(PROFILE).unwrap()[0].clone(); for field in [ "senateID", @@ -208,7 +208,6 @@ fn documented_fields_have_strict_types_and_only_position_end_date_is_nullable() "latestParty", "latestState", "latestPosition", - "image", "active", "yearsActive", ] { @@ -220,6 +219,14 @@ fn documented_fields_have_strict_types_and_only_position_end_date_is_nullable() ); } + let mut missing_image = profile.clone(); + missing_image.as_object_mut().unwrap().remove("image"); + assert!(serde_json::from_value::(missing_image).is_err()); + let mut null_image = profile.clone(); + null_image["image"] = serde_json::Value::Null; + let decoded = serde_json::from_value::(null_image).unwrap(); + assert_eq!(decoded.image, None); + let position = serde_json::from_slice::(POSITIONS).unwrap()[0].clone(); assert!(serde_json::from_value::(position.clone()).is_ok()); let mut missing_end_date = position.clone(); diff --git a/crates/libfmp/tests/esg_responses.rs b/crates/libfmp/tests/esg_responses.rs index 146fefa3..23ecc6df 100644 --- a/crates/libfmp/tests/esg_responses.rs +++ b/crates/libfmp/tests/esg_responses.rs @@ -18,7 +18,10 @@ fn all_documented_esg_fixtures_round_trip_exactly_with_exact_acronym_keys() { assert_eq!(disclosure.accepted_date.to_string(), "2026-04-30"); assert_eq!(disclosure.symbol.as_str(), "AAPL"); assert_eq!(disclosure.cik.as_str(), "0000320193"); - assert_eq!(disclosure.form_type.as_str(), "8-K"); + assert_eq!( + disclosure.form_type.as_ref().map(|form| form.as_str()), + Some("8-K") + ); assert_eq!(disclosure.esg_score, 56.79); let rating = rows::(RATINGS).remove(0); @@ -42,9 +45,9 @@ fn all_documented_esg_fixtures_round_trip_exactly_with_exact_acronym_keys() { #[test] fn documented_field_counts_required_non_null_contracts_and_unknown_tolerance_hold() { - assert_required_contract::(DISCLOSURES, 11); - assert_required_contract::(RATINGS, 7); - assert_required_contract::(BENCHMARK, 6); + assert_required_contract::(DISCLOSURES, 11, &["companyName", "formType"]); + assert_required_contract::(RATINGS, 7, &["companyName"]); + assert_required_contract::(BENCHMARK, 6, &[]); } #[test] @@ -81,9 +84,9 @@ where assert_eq!(serde_json::to_value(rows::(fixture)).unwrap(), source); } -fn assert_required_contract(fixture: &[u8], field_count: usize) +fn assert_required_contract(fixture: &[u8], field_count: usize, nullable: &[&str]) where - T: DeserializeOwned, + T: DeserializeOwned + Serialize, { let source: Value = serde_json::from_slice(fixture).unwrap(); let row = source[0].as_object().unwrap(); @@ -96,7 +99,12 @@ where let mut null = row.clone(); null.insert(field.clone(), Value::Null); - assert!(serde_json::from_value::(Value::Object(null)).is_err()); + let decoded = serde_json::from_value::(Value::Object(null)); + if nullable.contains(&field.as_str()) { + assert!(serde_json::to_value(decoded.unwrap()).unwrap()[field].is_null()); + } else { + assert!(decoded.is_err(), "field {field} must reject null"); + } } let mut future: Map = row.clone(); diff --git a/crates/libfmp/tests/market_hours_responses.rs b/crates/libfmp/tests/market_hours_responses.rs index c8961720..018bb4f1 100644 --- a/crates/libfmp/tests/market_hours_responses.rs +++ b/crates/libfmp/tests/market_hours_responses.rs @@ -71,6 +71,21 @@ fn adjusted_times_are_required_but_nullable_and_future_dynamic() { assert_eq!(serde_json::to_value(decoded).unwrap(), future); } +#[test] +fn is_closed_is_required_but_null_decodes_to_none() { + let source: Value = serde_json::from_slice(HOLIDAYS).unwrap(); + + let mut missing = source.clone(); + missing[0].as_object_mut().unwrap().remove("isClosed"); + assert!(serde_json::from_value::>(missing).is_err()); + + let mut null = source; + null[0]["isClosed"] = Value::Null; + let decoded: Vec = serde_json::from_value(null.clone()).unwrap(); + assert_eq!(decoded[0].is_closed, None); + assert_eq!(serde_json::to_value(decoded).unwrap(), null); +} + #[test] fn market_hours_contracts_preserve_bare_array_roots() { assert!( diff --git a/crates/libfmp/tests/news_responses.rs b/crates/libfmp/tests/news_responses.rs index bd3498e1..8529d1d6 100644 --- a/crates/libfmp/tests/news_responses.rs +++ b/crates/libfmp/tests/news_responses.rs @@ -66,7 +66,7 @@ fn all_nine_exact_provider_fixtures_share_the_same_eight_field_row() { assert_eq!(rows.len(), 1); assert_eq!(rows[0].symbol.as_ref().map(Ticker::as_str), symbol); assert_eq!(rows[0].publisher, publisher); - assert!(rows[0].image.starts_with("https://")); + assert!(rows[0].image.as_deref().unwrap().starts_with("https://")); assert!(rows[0].url.starts_with("https://")); } } @@ -108,6 +108,17 @@ fn symbol_key_is_required_but_explicit_null_is_preserved() { assert!(encoded["symbol"].is_null()); } +#[test] +fn image_and_site_are_required_but_null_decodes_to_none() { + let mut source: serde_json::Value = serde_json::from_slice(LATEST_CRYPTO).unwrap(); + source[0]["image"] = serde_json::Value::Null; + source[0]["site"] = serde_json::Value::Null; + let rows: Vec = serde_json::from_value(source.clone()).unwrap(); + assert_eq!(rows[0].image, None); + assert_eq!(rows[0].site, None); + assert_eq!(serde_json::to_value(&rows).unwrap(), source); +} + #[test] fn documented_fields_are_required_nullability_is_exact_and_unknowns_are_accepted() { assert_contract::
(FMP_ARTICLES, &[]); @@ -122,7 +133,7 @@ fn documented_fields_are_required_nullability_is_exact_and_unknowns_are_accepted SEARCH_CRYPTO, SEARCH_FOREX, ] { - assert_contract::(fixture, &["symbol"]); + assert_contract::(fixture, &["symbol", "image", "site"]); } } diff --git a/sdk/go/congressional_models.go b/sdk/go/congressional_models.go index 34fcba0f..2afad269 100644 --- a/sdk/go/congressional_models.go +++ b/sdk/go/congressional_models.go @@ -123,7 +123,7 @@ type CongressionalMemberProfile struct { LatestParty string `json:"latestParty"` LatestState string `json:"latestState"` LatestPosition string `json:"latestPosition"` - Image string `json:"image"` + Image *string `json:"image"` Active bool `json:"active"` YearsActive float64 `json:"yearsActive"` } @@ -132,16 +132,16 @@ type CongressionalMemberProfile struct { // pointer or raw value for every required member so a missing or null member // is observable after decoding. type congressionalMemberProfileShadow struct { - MemberID *string `json:"senateID"` - FirstName *string `json:"firstName"` - LastName *string `json:"lastName"` - BirthDate *Date `json:"birthDate"` - LatestParty *string `json:"latestParty"` - LatestState *string `json:"latestState"` - LatestPosition *string `json:"latestPosition"` - Image *string `json:"image"` - Active *bool `json:"active"` - YearsActive *float64 `json:"yearsActive"` + MemberID *string `json:"senateID"` + FirstName *string `json:"firstName"` + LastName *string `json:"lastName"` + BirthDate *Date `json:"birthDate"` + LatestParty *string `json:"latestParty"` + LatestState *string `json:"latestState"` + LatestPosition *string `json:"latestPosition"` + Image jsontext.Value `json:"image"` + Active *bool `json:"active"` + YearsActive *float64 `json:"yearsActive"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -167,13 +167,21 @@ func (m *CongressionalMemberProfile) UnmarshalJSONFrom(dec *jsontext.Decoder) er return missingMemberError("CongressionalMemberProfile", "latestState") case shadow.LatestPosition == nil: return missingMemberError("CongressionalMemberProfile", "latestPosition") - case shadow.Image == nil: + case len(shadow.Image) == 0: return missingMemberError("CongressionalMemberProfile", "image") case shadow.Active == nil: return missingMemberError("CongressionalMemberProfile", "active") case shadow.YearsActive == nil: return missingMemberError("CongressionalMemberProfile", "yearsActive") } + var image *string + if shadow.Image.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Image, &value); err != nil { + return err + } + image = &value + } *m = CongressionalMemberProfile{ MemberID: *shadow.MemberID, FirstName: *shadow.FirstName, @@ -182,7 +190,7 @@ func (m *CongressionalMemberProfile) UnmarshalJSONFrom(dec *jsontext.Decoder) er LatestParty: *shadow.LatestParty, LatestState: *shadow.LatestState, LatestPosition: *shadow.LatestPosition, - Image: *shadow.Image, + Image: image, Active: *shadow.Active, YearsActive: *shadow.YearsActive, } diff --git a/sdk/go/congressional_parity_test.go b/sdk/go/congressional_parity_test.go index d01110ed..ac9d4103 100644 --- a/sdk/go/congressional_parity_test.go +++ b/sdk/go/congressional_parity_test.go @@ -1,7 +1,9 @@ package fmp import ( + "encoding/json/jsontext" "encoding/json/v2" + "reflect" "strings" "testing" ) @@ -92,8 +94,8 @@ func TestDocumentedMemberAndNetWorthFixturesDecodeExactValues(t *testing.T) { profiles := assertFixtureParity[CongressionalMemberProfile](t, "congress_senate_profile.json") if want := (CongressionalMemberProfile{MemberID: "L000397", FirstName: "Zoe", LastName: "Lofgren", BirthDate: mustParseDate(t, "1947-12-20"), LatestParty: "Democrat", LatestState: "CA", LatestPosition: "Representative", - Image: "https://images.financialmodelingprep.com/senate/L000397.jpg", Active: true, - YearsActive: 31.6}); len(profiles) != 1 || profiles[0] != want { + Image: new("https://images.financialmodelingprep.com/senate/L000397.jpg"), Active: true, + YearsActive: 31.6}); len(profiles) != 1 || !reflect.DeepEqual(profiles[0], want) { t.Fatalf("congress_senate_profile = %+v", profiles) } @@ -224,3 +226,31 @@ func TestCongressionalRequiredMembersAreEnforcedLikeSerde(t *testing.T) { } } } + +// Mirrors documented_fields_have_strict_types_and_only_image_and_end_date_are_nullable in +// crates/libfmp/tests/congressional_member_endpoints.rs: members FMP sends as null +// decode to nil, while a missing member still fails. +func TestCongressionalMemberProfileImageDecodesNullAsNil(t *testing.T) { + t.Parallel() + const fixture = "congress_senate_profile.json" + cases := []struct { + member string + isNil func(CongressionalMemberProfile) bool + }{ + {"image", func(row CongressionalMemberProfile) bool { return row.Image == nil }}, + } + for _, tc := range cases { + t.Run(tc.member, func(t *testing.T) { + t.Parallel() + var missing []CongressionalMemberProfile + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, nil), &missing); err == nil { + t.Fatalf("missing %s decoded", tc.member) + } + var rows []CongressionalMemberProfile + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, jsontext.Value(`null`)), &rows); err != nil || + len(rows) != 1 || !tc.isNil(rows[0]) { + t.Fatalf("null %s = %+v, %v, want nil", tc.member, rows, err) + } + }) + } +} diff --git a/sdk/go/esg_models.go b/sdk/go/esg_models.go index 12b84b50..f88a9087 100644 --- a/sdk/go/esg_models.go +++ b/sdk/go/esg_models.go @@ -16,8 +16,8 @@ type ESGDisclosure struct { AcceptedDate Date `json:"acceptedDate"` Symbol string `json:"symbol"` CIK string `json:"cik"` - CompanyName string `json:"companyName"` - FormType string `json:"formType"` + CompanyName *string `json:"companyName"` + FormType *string `json:"formType"` EnvironmentalScore float64 `json:"environmentalScore"` SocialScore float64 `json:"socialScore"` GovernanceScore float64 `json:"governanceScore"` @@ -29,17 +29,17 @@ type ESGDisclosure struct { // every required member so a missing or null member is observable after // decoding. type esgDisclosureShadow struct { - Date *Date `json:"date"` - AcceptedDate *Date `json:"acceptedDate"` - Symbol *string `json:"symbol"` - CIK *string `json:"cik"` - CompanyName *string `json:"companyName"` - FormType *string `json:"formType"` - EnvironmentalScore *float64 `json:"environmentalScore"` - SocialScore *float64 `json:"socialScore"` - GovernanceScore *float64 `json:"governanceScore"` - ESGScore *float64 `json:"ESGScore"` - URL *string `json:"url"` + Date *Date `json:"date"` + AcceptedDate *Date `json:"acceptedDate"` + Symbol *string `json:"symbol"` + CIK *string `json:"cik"` + CompanyName jsontext.Value `json:"companyName"` + FormType jsontext.Value `json:"formType"` + EnvironmentalScore *float64 `json:"environmentalScore"` + SocialScore *float64 `json:"socialScore"` + GovernanceScore *float64 `json:"governanceScore"` + ESGScore *float64 `json:"ESGScore"` + URL *string `json:"url"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -59,9 +59,9 @@ func (m *ESGDisclosure) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("ESGDisclosure", "symbol") case shadow.CIK == nil: return missingMemberError("ESGDisclosure", "cik") - case shadow.CompanyName == nil: + case len(shadow.CompanyName) == 0: return missingMemberError("ESGDisclosure", "companyName") - case shadow.FormType == nil: + case len(shadow.FormType) == 0: return missingMemberError("ESGDisclosure", "formType") case shadow.EnvironmentalScore == nil: return missingMemberError("ESGDisclosure", "environmentalScore") @@ -74,13 +74,29 @@ func (m *ESGDisclosure) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.URL == nil: return missingMemberError("ESGDisclosure", "url") } + var companyName *string + if shadow.CompanyName.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.CompanyName, &value); err != nil { + return err + } + companyName = &value + } + var formType *string + if shadow.FormType.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.FormType, &value); err != nil { + return err + } + formType = &value + } *m = ESGDisclosure{ Date: *shadow.Date, AcceptedDate: *shadow.AcceptedDate, Symbol: *shadow.Symbol, CIK: *shadow.CIK, - CompanyName: *shadow.CompanyName, - FormType: *shadow.FormType, + CompanyName: companyName, + FormType: formType, EnvironmentalScore: *shadow.EnvironmentalScore, SocialScore: *shadow.SocialScore, GovernanceScore: *shadow.GovernanceScore, @@ -92,25 +108,25 @@ func (m *ESGDisclosure) UnmarshalJSONFrom(dec *jsontext.Decoder) error { // ESGRating is one company's ESG risk rating for a fiscal year. type ESGRating struct { - Symbol string `json:"symbol"` - CIK string `json:"cik"` - CompanyName string `json:"companyName"` - Industry string `json:"industry"` - FiscalYear uint32 `json:"fiscalYear"` - ESGRiskRating string `json:"ESGRiskRating"` - IndustryRank string `json:"industryRank"` + Symbol string `json:"symbol"` + CIK string `json:"cik"` + CompanyName *string `json:"companyName"` + Industry string `json:"industry"` + FiscalYear uint32 `json:"fiscalYear"` + ESGRiskRating string `json:"ESGRiskRating"` + IndustryRank string `json:"industryRank"` } // esgRatingShadow mirrors ESGRating with a pointer or raw value for every // required member so a missing or null member is observable after decoding. type esgRatingShadow struct { - Symbol *string `json:"symbol"` - CIK *string `json:"cik"` - CompanyName *string `json:"companyName"` - Industry *string `json:"industry"` - FiscalYear *uint32 `json:"fiscalYear"` - ESGRiskRating *string `json:"ESGRiskRating"` - IndustryRank *string `json:"industryRank"` + Symbol *string `json:"symbol"` + CIK *string `json:"cik"` + CompanyName jsontext.Value `json:"companyName"` + Industry *string `json:"industry"` + FiscalYear *uint32 `json:"fiscalYear"` + ESGRiskRating *string `json:"ESGRiskRating"` + IndustryRank *string `json:"industryRank"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -126,7 +142,7 @@ func (m *ESGRating) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("ESGRating", "symbol") case shadow.CIK == nil: return missingMemberError("ESGRating", "cik") - case shadow.CompanyName == nil: + case len(shadow.CompanyName) == 0: return missingMemberError("ESGRating", "companyName") case shadow.Industry == nil: return missingMemberError("ESGRating", "industry") @@ -137,10 +153,18 @@ func (m *ESGRating) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.IndustryRank == nil: return missingMemberError("ESGRating", "industryRank") } + var companyName *string + if shadow.CompanyName.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.CompanyName, &value); err != nil { + return err + } + companyName = &value + } *m = ESGRating{ Symbol: *shadow.Symbol, CIK: *shadow.CIK, - CompanyName: *shadow.CompanyName, + CompanyName: companyName, Industry: *shadow.Industry, FiscalYear: *shadow.FiscalYear, ESGRiskRating: *shadow.ESGRiskRating, diff --git a/sdk/go/esg_parity_test.go b/sdk/go/esg_parity_test.go index 134192b4..106e967f 100644 --- a/sdk/go/esg_parity_test.go +++ b/sdk/go/esg_parity_test.go @@ -4,6 +4,7 @@ import ( "encoding/json/jsontext" "encoding/json/v2" "errors" + "reflect" "slices" "testing" ) @@ -27,8 +28,8 @@ func TestDocumentedEsgDisclosureDecodesExactValuesAndKeepsAcronymKey(t *testing. } disclosure := disclosures[0] if disclosure.Date.String() != "2026-03-28" || disclosure.AcceptedDate.String() != "2026-04-30" || - disclosure.Symbol != "AAPL" || disclosure.CIK != "0000320193" || disclosure.CompanyName != "Apple Inc." || - disclosure.FormType != "8-K" || disclosure.EnvironmentalScore != 66.29 || disclosure.SocialScore != 45.21 || + disclosure.Symbol != "AAPL" || disclosure.CIK != "0000320193" || !reflect.DeepEqual(disclosure.CompanyName, new("Apple Inc.")) || + !reflect.DeepEqual(disclosure.FormType, new("8-K")) || disclosure.EnvironmentalScore != 66.29 || disclosure.SocialScore != 45.21 || disclosure.GovernanceScore != 58.87 || disclosure.ESGScore != 56.79 || disclosure.URL != "https://www.sec.gov/Archives/edgar/data/320193/000032019326000011/0000320193-26-000011-index.htm" { t.Fatalf("esg_disclosures = %+v", disclosure) @@ -47,7 +48,7 @@ func TestDocumentedEsgRatingAndBenchmarkDecodeExactValues(t *testing.T) { t.Fatalf("rows = %d, want 1", len(ratings)) } rating := ratings[0] - if rating.Symbol != "AAPL" || rating.CIK != "0000320193" || rating.CompanyName != "Apple Inc." || + if rating.Symbol != "AAPL" || rating.CIK != "0000320193" || !reflect.DeepEqual(rating.CompanyName, new("Apple Inc.")) || rating.Industry != "CONSUMER ELECTRONICS" || rating.FiscalYear != 2025 || rating.ESGRiskRating != "B" || rating.IndustryRank != "17 out of 20" { t.Fatalf("esg_ratings = %+v", rating) @@ -129,3 +130,60 @@ func TestEsgRequiredMembersAreEnforcedLikeSerde(t *testing.T) { t.Fatal("a benchmark without scores decoded into ESGBenchmark") } } + +// Mirrors assert_required_contract in +// crates/libfmp/tests/esg_responses.rs: members FMP sends as null +// decode to nil, while a missing member still fails. +func TestEsgDisclosureNullableMembersDecodeNullAsNil(t *testing.T) { + t.Parallel() + const fixture = "esg_disclosures.json" + cases := []struct { + member string + isNil func(ESGDisclosure) bool + }{ + {"companyName", func(row ESGDisclosure) bool { return row.CompanyName == nil }}, + {"formType", func(row ESGDisclosure) bool { return row.FormType == nil }}, + } + for _, tc := range cases { + t.Run(tc.member, func(t *testing.T) { + t.Parallel() + var missing []ESGDisclosure + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, nil), &missing); err == nil { + t.Fatalf("missing %s decoded", tc.member) + } + var rows []ESGDisclosure + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, jsontext.Value(`null`)), &rows); err != nil || + len(rows) != 1 || !tc.isNil(rows[0]) { + t.Fatalf("null %s = %+v, %v, want nil", tc.member, rows, err) + } + }) + } +} + +// Mirrors assert_required_contract in +// crates/libfmp/tests/esg_responses.rs: members FMP sends as null +// decode to nil, while a missing member still fails. +func TestEsgRatingCompanyNameDecodesNullAsNil(t *testing.T) { + t.Parallel() + const fixture = "esg_ratings.json" + cases := []struct { + member string + isNil func(ESGRating) bool + }{ + {"companyName", func(row ESGRating) bool { return row.CompanyName == nil }}, + } + for _, tc := range cases { + t.Run(tc.member, func(t *testing.T) { + t.Parallel() + var missing []ESGRating + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, nil), &missing); err == nil { + t.Fatalf("missing %s decoded", tc.member) + } + var rows []ESGRating + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, jsontext.Value(`null`)), &rows); err != nil || + len(rows) != 1 || !tc.isNil(rows[0]) { + t.Fatalf("null %s = %+v, %v, want nil", tc.member, rows, err) + } + }) + } +} diff --git a/sdk/go/market_hours_models.go b/sdk/go/market_hours_models.go index ad9ae267..aa93bcf9 100644 --- a/sdk/go/market_hours_models.go +++ b/sdk/go/market_hours_models.go @@ -70,7 +70,7 @@ type ExchangeHoliday struct { Exchange string `json:"exchange"` Date Date `json:"date"` Name string `json:"name"` - IsClosed bool `json:"isClosed"` + IsClosed *bool `json:"isClosed"` AdjOpenTime *jsontext.Value `json:"adjOpenTime"` AdjCloseTime *jsontext.Value `json:"adjCloseTime"` } @@ -82,7 +82,7 @@ type exchangeHolidayShadow struct { Exchange *string `json:"exchange"` Date *Date `json:"date"` Name *string `json:"name"` - IsClosed *bool `json:"isClosed"` + IsClosed jsontext.Value `json:"isClosed"` AdjOpenTime jsontext.Value `json:"adjOpenTime"` AdjCloseTime jsontext.Value `json:"adjCloseTime"` } @@ -102,13 +102,21 @@ func (m *ExchangeHoliday) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("ExchangeHoliday", "date") case shadow.Name == nil: return missingMemberError("ExchangeHoliday", "name") - case shadow.IsClosed == nil: + case len(shadow.IsClosed) == 0: return missingMemberError("ExchangeHoliday", "isClosed") case len(shadow.AdjOpenTime) == 0: return missingMemberError("ExchangeHoliday", "adjOpenTime") case len(shadow.AdjCloseTime) == 0: return missingMemberError("ExchangeHoliday", "adjCloseTime") } + var isClosed *bool + if shadow.IsClosed.Kind() != 'n' { + var value bool + if err := json.Unmarshal(shadow.IsClosed, &value); err != nil { + return err + } + isClosed = &value + } var adjOpenTime *jsontext.Value if shadow.AdjOpenTime.Kind() != 'n' { var value jsontext.Value @@ -129,7 +137,7 @@ func (m *ExchangeHoliday) UnmarshalJSONFrom(dec *jsontext.Decoder) error { Exchange: *shadow.Exchange, Date: *shadow.Date, Name: *shadow.Name, - IsClosed: *shadow.IsClosed, + IsClosed: isClosed, AdjOpenTime: adjOpenTime, AdjCloseTime: adjCloseTime, } diff --git a/sdk/go/market_hours_parity_test.go b/sdk/go/market_hours_parity_test.go index e46e8676..6e75b97a 100644 --- a/sdk/go/market_hours_parity_test.go +++ b/sdk/go/market_hours_parity_test.go @@ -4,6 +4,7 @@ import ( "encoding/json/jsontext" "encoding/json/v2" "errors" + "reflect" "testing" ) @@ -60,7 +61,7 @@ func TestDocumentedExchangeHolidaysDecodeExactValues(t *testing.T) { } row := holidays[0] if row.Exchange != "NASDAQ" || row.Date != mustParseDate(t, "2026-07-03") || row.Date.String() != "2026-07-03" || - row.Name != "Independence Day" || !row.IsClosed || row.AdjOpenTime != nil || row.AdjCloseTime != nil { + row.Name != "Independence Day" || !reflect.DeepEqual(row.IsClosed, new(true)) || row.AdjOpenTime != nil || row.AdjCloseTime != nil { t.Fatalf("holidays_by_exchange = %+v", row) } if members := memberSet(t, row); len(members) != 6 { @@ -142,7 +143,7 @@ func TestMarketHoursRequiredMembersAndArrayRootsAreEnforcedLikeSerde(t *testing. `required member "isMarketOpen" of ExchangeMarketHours is missing or null`}, {"missing date", holidays, "date", nil, `required member "date" of ExchangeHoliday is missing or null`}, - {"null closed flag", holidays, "isClosed", jsontext.Value(`null`), + {"missing closed flag", holidays, "isClosed", nil, `required member "isClosed" of ExchangeHoliday is missing or null`}, } for _, tc := range rejected { @@ -171,3 +172,31 @@ func TestMarketHoursRequiredMembersAndArrayRootsAreEnforcedLikeSerde(t *testing. } } } + +// Mirrors is_closed_is_required_but_null_decodes_to_none in +// crates/libfmp/tests/market_hours_responses.rs: members FMP sends as null +// decode to nil, while a missing member still fails. +func TestExchangeHolidayIsClosedDecodesNullAsNil(t *testing.T) { + t.Parallel() + const fixture = "holidays_by_exchange.json" + cases := []struct { + member string + isNil func(ExchangeHoliday) bool + }{ + {"isClosed", func(row ExchangeHoliday) bool { return row.IsClosed == nil }}, + } + for _, tc := range cases { + t.Run(tc.member, func(t *testing.T) { + t.Parallel() + var missing []ExchangeHoliday + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, nil), &missing); err == nil { + t.Fatalf("missing %s decoded", tc.member) + } + var rows []ExchangeHoliday + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, jsontext.Value(`null`)), &rows); err != nil || + len(rows) != 1 || !tc.isNil(rows[0]) { + t.Fatalf("null %s = %+v, %v, want nil", tc.member, rows, err) + } + }) + } +} diff --git a/sdk/go/news_models.go b/sdk/go/news_models.go index 1aa9ef5f..449eaad7 100644 --- a/sdk/go/news_models.go +++ b/sdk/go/news_models.go @@ -81,8 +81,8 @@ type NewsArticle struct { PublishedDate DateTime `json:"publishedDate"` Publisher string `json:"publisher"` Title string `json:"title"` - Image string `json:"image"` - Site string `json:"site"` + Image *string `json:"image"` + Site *string `json:"site"` Text string `json:"text"` URL string `json:"url"` } @@ -94,8 +94,8 @@ type newsArticleShadow struct { PublishedDate *DateTime `json:"publishedDate"` Publisher *string `json:"publisher"` Title *string `json:"title"` - Image *string `json:"image"` - Site *string `json:"site"` + Image jsontext.Value `json:"image"` + Site jsontext.Value `json:"site"` Text *string `json:"text"` URL *string `json:"url"` } @@ -117,9 +117,9 @@ func (m *NewsArticle) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("NewsArticle", "publisher") case shadow.Title == nil: return missingMemberError("NewsArticle", "title") - case shadow.Image == nil: + case len(shadow.Image) == 0: return missingMemberError("NewsArticle", "image") - case shadow.Site == nil: + case len(shadow.Site) == 0: return missingMemberError("NewsArticle", "site") case shadow.Text == nil: return missingMemberError("NewsArticle", "text") @@ -134,13 +134,29 @@ func (m *NewsArticle) UnmarshalJSONFrom(dec *jsontext.Decoder) error { } symbol = &value } + var image *string + if shadow.Image.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Image, &value); err != nil { + return err + } + image = &value + } + var site *string + if shadow.Site.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Site, &value); err != nil { + return err + } + site = &value + } *m = NewsArticle{ Symbol: symbol, PublishedDate: *shadow.PublishedDate, Publisher: *shadow.Publisher, Title: *shadow.Title, - Image: *shadow.Image, - Site: *shadow.Site, + Image: image, + Site: site, Text: *shadow.Text, URL: *shadow.URL, } diff --git a/sdk/go/news_parity_test.go b/sdk/go/news_parity_test.go index e0f731c2..ebb29fbf 100644 --- a/sdk/go/news_parity_test.go +++ b/sdk/go/news_parity_test.go @@ -1,6 +1,7 @@ package fmp import ( + "encoding/json/jsontext" "encoding/json/v2" "errors" "strings" @@ -88,8 +89,8 @@ func TestAllNineProviderNewsFixturesShareTheSameEightFieldRow(t *testing.T) { if row.Publisher != tc.publisher { t.Fatalf("%s: publisher = %q, want %q", tc.fixture, row.Publisher, tc.publisher) } - if !strings.HasPrefix(row.Image, "https://") || !strings.HasPrefix(row.URL, "https://") { - t.Fatalf("%s: image %q or url %q is not https", tc.fixture, row.Image, row.URL) + if row.Image == nil || !strings.HasPrefix(*row.Image, "https://") || !strings.HasPrefix(row.URL, "https://") { + t.Fatalf("%s: image %v or url %q is not https", tc.fixture, row.Image, row.URL) } if got := memberSet(t, row); len(got) != 8 { t.Fatalf("%s: re-encoded members = %d, want 8", tc.fixture, len(got)) @@ -244,3 +245,32 @@ func TestNewsRowsAreBareArraysPreservingEmptyMultipleAndLargeOpaqueStrings(t *te t.Fatalf("large content: len %d, %v", len(articles), err) } } + +// Mirrors image_and_site_are_required_but_null_decodes_to_none in +// crates/libfmp/tests/news_responses.rs: members FMP sends as null +// decode to nil, while a missing member still fails. +func TestNewsImageAndSiteDecodeNullAsNil(t *testing.T) { + t.Parallel() + const fixture = "latest_crypto_news.json" + cases := []struct { + member string + isNil func(NewsArticle) bool + }{ + {"image", func(row NewsArticle) bool { return row.Image == nil }}, + {"site", func(row NewsArticle) bool { return row.Site == nil }}, + } + for _, tc := range cases { + t.Run(tc.member, func(t *testing.T) { + t.Parallel() + var missing []NewsArticle + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, nil), &missing); err == nil { + t.Fatalf("missing %s decoded", tc.member) + } + var rows []NewsArticle + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, jsontext.Value(`null`)), &rows); err != nil || + len(rows) != 1 || !tc.isNil(rows[0]) { + t.Fatalf("null %s = %+v, %v, want nil", tc.member, rows, err) + } + }) + } +} From 7ce60583b822f616289df37f6ee1cf3b3508c7bd Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 21:31:44 +0800 Subject: [PATCH 2/3] feat(crypto): make omittable CryptocurrencyListing members optional The provider can omit `icoDate`, `circulatingSupply`, and `totalSupply` from a cryptocurrency listing, so these members are now optional across the SDKs instead of required non-null values. **Rust wire model:** * `CryptocurrencyListing` in `crates/libfmp` now types `ico_date`, `circulating_supply`, and `total_supply` as `Option`, keeping the members required but nullable and decoding an empty `icoDate` to `None` via `empty_or_null_date`. * Supply members serialize through `integral_f64::serialize_option` so integral values still round-trip as integers. **Python bindings:** * The `CryptocurrencyListing` model and its stubs expose the three members as `Option` / `typing.Optional`, mapping the inner date only when present. **Go SDK:** * `CryptocurrencyListing` holds pointer members and decodes null or empty values to `nil` through a `jsontext.Value` shadow, while still rejecting missing members. **Tests:** * Added coverage in all three SDKs for null supplies and empty ICO dates decoding to `None` / `nil`, and updated existing assertions to the optional representation. BREAKING CHANGE: CryptocurrencyListing members FMP can omit are now optional (Option / pointer / Optional); handle None. --- .../python/fmp/_native/crypto/__init__.pyi | 12 ++-- crates/fmp-py/src/models/crypto.rs | 14 ++--- crates/fmp-py/tests/test_crypto.py | 14 +++++ crates/fmp-py/tests/typecheck_contract.py | 4 +- crates/libfmp/src/responses/crypto.rs | 28 +++++++--- .../tests/asset_catalog_quote_endpoints.rs | 2 +- .../tests/asset_catalog_quote_responses.rs | 46 +++++++++++---- crates/libfmp/tests/integral_f64_responses.rs | 8 +-- sdk/go/crypto_models.go | 56 +++++++++++++------ sdk/go/crypto_parity_test.go | 39 +++++++++++-- sdk/go/crypto_test.go | 2 +- sdk/go/integral_f64_parity_test.go | 8 ++- 12 files changed, 169 insertions(+), 64 deletions(-) diff --git a/crates/fmp-py/python/fmp/_native/crypto/__init__.pyi b/crates/fmp-py/python/fmp/_native/crypto/__init__.pyi index d277d2e3..f94db3d4 100644 --- a/crates/fmp-py/python/fmp/_native/crypto/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/crypto/__init__.pyi @@ -112,11 +112,11 @@ class CryptocurrencyListing: @property def exchange(self) -> builtins.str: ... @property - def ico_date(self) -> datetime.date: ... + def ico_date(self) -> typing.Optional[datetime.date]: ... @property - def circulating_supply(self) -> builtins.float: ... + def circulating_supply(self) -> typing.Optional[builtins.float]: ... @property - def total_supply(self) -> builtins.float: ... + def total_supply(self) -> typing.Optional[builtins.float]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, @@ -124,9 +124,9 @@ class CryptocurrencyListing: symbol: builtins.str, name: builtins.str, exchange: builtins.str, - ico_date: datetime.date, - circulating_supply: builtins.float, - total_supply: builtins.float, + ico_date: typing.Optional[datetime.date], + circulating_supply: typing.Optional[builtins.float], + total_supply: typing.Optional[builtins.float], ) -> CryptocurrencyListing: ... def __getnewargs_ex__(self) -> tuple[tuple, dict]: ... def __repr__(self) -> builtins.str: ... diff --git a/crates/fmp-py/src/models/crypto.rs b/crates/fmp-py/src/models/crypto.rs index 618c1670..ab7b100b 100644 --- a/crates/fmp-py/src/models/crypto.rs +++ b/crates/fmp-py/src/models/crypto.rs @@ -16,11 +16,11 @@ pub(crate) struct CryptocurrencyListing { #[pyo3(get)] pub exchange: String, #[pyo3(get)] - pub ico_date: ::chrono::NaiveDate, + pub ico_date: Option<::chrono::NaiveDate>, #[pyo3(get)] - pub circulating_supply: f64, + pub circulating_supply: Option, #[pyo3(get)] - pub total_supply: f64, + pub total_supply: Option, } #[gen_stub_pymethods] @@ -34,9 +34,9 @@ impl CryptocurrencyListing { symbol: String, name: String, exchange: String, - ico_date: ::chrono::NaiveDate, - circulating_supply: f64, - total_supply: f64, + ico_date: Option<::chrono::NaiveDate>, + circulating_supply: Option, + total_supply: Option, ) -> Self { Self { symbol, @@ -147,7 +147,7 @@ impl From for CryptocurrencyLi symbol: value.symbol.into_inner(), name: value.name, exchange: value.exchange.into_inner(), - ico_date: value.ico_date.into_inner(), + ico_date: value.ico_date.map(|value| value.into_inner()), circulating_supply: value.circulating_supply, total_supply: value.total_supply, } diff --git a/crates/fmp-py/tests/test_crypto.py b/crates/fmp-py/tests/test_crypto.py index c928e2eb..904fbe20 100644 --- a/crates/fmp-py/tests/test_crypto.py +++ b/crates/fmp-py/tests/test_crypto.py @@ -271,3 +271,17 @@ def test_decode_error_names_the_chart_endpoint( with pytest.raises(errors.FmpDecodeError) as raised: client.crypto.chart_one_minute("BTCUSD") assert raised.value.endpoint == "historical-chart/1min" + + +def test_list_decodes_null_supplies_and_empty_ico_date_to_none(client: Any, fixture_server: FixtureServer) -> None: + """Null supplies and an empty ``icoDate`` decode to ``None``.""" + body = load_fixture("cryptocurrency_list.json") + body[0]["icoDate"] = "" + body[0]["circulatingSupply"] = None + body[0]["totalSupply"] = None + fixture_server.route("/cryptocurrency-list", body) + rows = client.crypto.list() + + assert rows[0].ico_date is None + assert rows[0].circulating_supply is None + assert rows[0].total_supply is None diff --git a/crates/fmp-py/tests/typecheck_contract.py b/crates/fmp-py/tests/typecheck_contract.py index 73bdc28a..57b77b69 100644 --- a/crates/fmp-py/tests/typecheck_contract.py +++ b/crates/fmp-py/tests/typecheck_contract.py @@ -400,8 +400,8 @@ def check_crypto_contract(client: FmpClient) -> None: """Type-check the crypto namespace, its keyword-only date filters, and the cross-module rows.""" crypto: CryptoNamespace = client.crypto listings: list[CryptocurrencyListing] = crypto.list() - ico_date: datetime.date = listings[0].ico_date - total_supply: float = listings[0].total_supply + ico_date: datetime.date | None = listings[0].ico_date + total_supply: float | None = listings[0].total_supply quotes: list[Quote] = client.crypto.quote("BTCUSD") market_cap: float | None = quotes[0].market_cap bars: list[StockChartFullBar] = client.crypto.chart_full( diff --git a/crates/libfmp/src/responses/crypto.rs b/crates/libfmp/src/responses/crypto.rs index e6376f07..64e13949 100644 --- a/crates/libfmp/src/responses/crypto.rs +++ b/crates/libfmp/src/responses/crypto.rs @@ -2,9 +2,20 @@ //! //! Detailed and compact cryptocurrency quotes reuse the shared quote wire contracts. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; -use crate::types::{Date, ExchangeCode, Ticker, TokenSupply}; +use crate::{ + codecs::empty_or_null_date, + types::{Date, ExchangeCode, Ticker, TokenSupply}, +}; + +fn required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} pub use super::quote::{Quote, QuoteShort}; @@ -16,9 +27,12 @@ pub struct CryptocurrencyListing { pub symbol: Ticker, pub name: String, pub exchange: ExchangeCode, - pub ico_date: Date, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub circulating_supply: TokenSupply, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub total_supply: TokenSupply, + #[serde(with = "empty_or_null_date")] + pub ico_date: Option, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub circulating_supply: Option, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub total_supply: Option, } diff --git a/crates/libfmp/tests/asset_catalog_quote_endpoints.rs b/crates/libfmp/tests/asset_catalog_quote_endpoints.rs index b4319b6a..c3fdf637 100644 --- a/crates/libfmp/tests/asset_catalog_quote_endpoints.rs +++ b/crates/libfmp/tests/asset_catalog_quote_endpoints.rs @@ -189,7 +189,7 @@ async fn proxy_routing_uses_all_asset_facades_custom_auth_default_headers_and_ex assert_eq!(forex[0].market_cap, None); assert_eq!(forex_short[0].volume, Some(146_872.0)); assert_eq!(forex_batch[0].change, Some(-0.00513532)); - assert_eq!(cryptocurrencies[0].total_supply, 4_788_606_639.0); + assert_eq!(cryptocurrencies[0].total_supply, Some(4_788_606_639.0)); assert_eq!(crypto[0].market_cap, Some(1_293_361_815_015.0)); assert_eq!(crypto_short[0].volume, Some(32_030_003_200.0)); assert_eq!(crypto_batch[0].symbol.as_str(), "00USD"); diff --git a/crates/libfmp/tests/asset_catalog_quote_responses.rs b/crates/libfmp/tests/asset_catalog_quote_responses.rs index 1025ee96..ca5dd241 100644 --- a/crates/libfmp/tests/asset_catalog_quote_responses.rs +++ b/crates/libfmp/tests/asset_catalog_quote_responses.rs @@ -156,10 +156,13 @@ fn catalog_rows_preserve_exact_identifiers_dates_and_large_unsigned_supplies() { serde_json::from_slice(CRYPTOCURRENCY_LIST).unwrap(); assert_eq!( cryptocurrencies[0].ico_date, - "2017-11-09".parse::().unwrap() + Some("2017-11-09".parse::().unwrap()) ); - assert_eq!(cryptocurrencies[0].circulating_supply, 4_232_705_124.0); - assert_eq!(cryptocurrencies[0].total_supply, 4_788_606_639.0); + assert_eq!( + cryptocurrencies[0].circulating_supply, + Some(4_232_705_124.0) + ); + assert_eq!(cryptocurrencies[0].total_supply, Some(4_788_606_639.0)); } #[test] @@ -193,17 +196,38 @@ fn catalog_non_nullable_fields_are_required_and_unknown_fields_are_accepted() { ); assert_required_non_null::( CRYPTOCURRENCY_LIST, - &[ - "symbol", - "name", - "exchange", - "icoDate", - "circulatingSupply", - "totalSupply", - ], + &["symbol", "name", "exchange"], ); } +#[test] +fn crypto_listing_supplies_and_ico_date_are_required_but_nullable() { + let source: Value = serde_json::from_slice(CRYPTOCURRENCY_LIST).unwrap(); + for field in ["icoDate", "circulatingSupply", "totalSupply"] { + let mut missing = source.clone(); + missing[0].as_object_mut().unwrap().remove(field); + assert!( + serde_json::from_value::>(missing).is_err(), + "missing {field}" + ); + } + + let mut null = source.clone(); + null[0]["icoDate"] = Value::Null; + null[0]["circulatingSupply"] = Value::Null; + null[0]["totalSupply"] = Value::Null; + let rows: Vec = serde_json::from_value(null.clone()).unwrap(); + assert_eq!(rows[0].ico_date, None); + assert_eq!(rows[0].circulating_supply, None); + assert_eq!(rows[0].total_supply, None); + assert_eq!(serde_json::to_value(&rows).unwrap(), null); + + let mut empty = source; + empty[0]["icoDate"] = json!(""); + let rows: Vec = serde_json::from_value(empty).unwrap(); + assert_eq!(rows[0].ico_date, None); +} + fn assert_required_non_null(fixture: &[u8], fields: &[&str]) where T: DeserializeOwned, diff --git a/crates/libfmp/tests/integral_f64_responses.rs b/crates/libfmp/tests/integral_f64_responses.rs index 1e8a12aa..5f7ade39 100644 --- a/crates/libfmp/tests/integral_f64_responses.rs +++ b/crates/libfmp/tests/integral_f64_responses.rs @@ -53,8 +53,8 @@ fn market_cap_decodes_integral_float_and_fractional_forms() { fn crypto_supply_decodes_integral_float_and_fractional_forms() { let rows: Vec = serde_json::from_slice(CRYPTO).unwrap(); - assert_eq!(rows[0].circulating_supply, 4_232_705_124.5); - assert_eq!(rows[0].total_supply, 4_788_606_639.0); + assert_eq!(rows[0].circulating_supply, Some(4_232_705_124.5)); + assert_eq!(rows[0].total_supply, Some(4_788_606_639.0)); let wire = serde_json::to_value(&rows).unwrap(); assert_eq!(wire[0]["circulatingSupply"], json!(4_232_705_124.5)); assert_eq!(wire[0]["totalSupply"], json!(4_788_606_639_u64)); @@ -205,8 +205,8 @@ fn exponent_form_numbers_decode_into_every_integral_f64_alias() { "circulatingSupply":4.2327051245e9,"totalSupply":4.788606639e9}"#, ) .unwrap(); - assert_eq!(crypto.circulating_supply, 4_232_705_124.5); - assert_eq!(crypto.total_supply, 4_788_606_639.0); + assert_eq!(crypto.circulating_supply, Some(4_232_705_124.5)); + assert_eq!(crypto.total_supply, Some(4_788_606_639.0)); assert_eq!( serde_json::to_value(&crypto).unwrap()["totalSupply"], json!(4_788_606_639_u64) diff --git a/sdk/go/crypto_models.go b/sdk/go/crypto_models.go index b2fa216f..c59fb639 100644 --- a/sdk/go/crypto_models.go +++ b/sdk/go/crypto_models.go @@ -13,24 +13,24 @@ import ( // CryptocurrencyListing is one cryptocurrency in the provider's documented // cryptocurrency catalog. type CryptocurrencyListing struct { - Symbol string `json:"symbol"` - Name string `json:"name"` - Exchange string `json:"exchange"` - IcoDate Date `json:"icoDate"` - CirculatingSupply float64 `json:"circulatingSupply"` - TotalSupply float64 `json:"totalSupply"` + Symbol string `json:"symbol"` + Name string `json:"name"` + Exchange string `json:"exchange"` + IcoDate *Date `json:"icoDate"` + CirculatingSupply *float64 `json:"circulatingSupply"` + TotalSupply *float64 `json:"totalSupply"` } // cryptocurrencyListingShadow mirrors CryptocurrencyListing with a pointer or // raw value for every required member so a missing or null member is // observable after decoding. type cryptocurrencyListingShadow struct { - Symbol *string `json:"symbol"` - Name *string `json:"name"` - Exchange *string `json:"exchange"` - IcoDate *Date `json:"icoDate"` - CirculatingSupply *float64 `json:"circulatingSupply"` - TotalSupply *float64 `json:"totalSupply"` + Symbol *string `json:"symbol"` + Name *string `json:"name"` + Exchange *string `json:"exchange"` + IcoDate jsontext.Value `json:"icoDate"` + CirculatingSupply jsontext.Value `json:"circulatingSupply"` + TotalSupply jsontext.Value `json:"totalSupply"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -48,20 +48,40 @@ func (m *CryptocurrencyListing) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("CryptocurrencyListing", "name") case shadow.Exchange == nil: return missingMemberError("CryptocurrencyListing", "exchange") - case shadow.IcoDate == nil: + case len(shadow.IcoDate) == 0: return missingMemberError("CryptocurrencyListing", "icoDate") - case shadow.CirculatingSupply == nil: + case len(shadow.CirculatingSupply) == 0: return missingMemberError("CryptocurrencyListing", "circulatingSupply") - case shadow.TotalSupply == nil: + case len(shadow.TotalSupply) == 0: return missingMemberError("CryptocurrencyListing", "totalSupply") } + icoDate, err := decodeEmptyDate("CryptocurrencyListing", "icoDate", shadow.IcoDate, true) + if err != nil { + return err + } + var circulatingSupply *float64 + if shadow.CirculatingSupply.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.CirculatingSupply, &value); err != nil { + return err + } + circulatingSupply = &value + } + var totalSupply *float64 + if shadow.TotalSupply.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.TotalSupply, &value); err != nil { + return err + } + totalSupply = &value + } *m = CryptocurrencyListing{ Symbol: *shadow.Symbol, Name: *shadow.Name, Exchange: *shadow.Exchange, - IcoDate: *shadow.IcoDate, - CirculatingSupply: *shadow.CirculatingSupply, - TotalSupply: *shadow.TotalSupply, + IcoDate: icoDate, + CirculatingSupply: circulatingSupply, + TotalSupply: totalSupply, } return nil } diff --git a/sdk/go/crypto_parity_test.go b/sdk/go/crypto_parity_test.go index d82a445e..5c7361f4 100644 --- a/sdk/go/crypto_parity_test.go +++ b/sdk/go/crypto_parity_test.go @@ -4,6 +4,8 @@ import ( "encoding/json/jsontext" "encoding/json/v2" "errors" + "reflect" + "slices" "strings" "testing" ) @@ -37,8 +39,8 @@ func TestCryptoCatalogAndQuoteFixturesDecodeExactValues(t *testing.T) { t.Parallel() listing := assertFixtureParity[CryptocurrencyListing](t, "cryptocurrency_list.json") want := CryptocurrencyListing{Symbol: "MIOTAUSD", Name: "IOTA USD", Exchange: "CCC", - IcoDate: mustParseDate(t, "2017-11-09"), CirculatingSupply: 4_232_705_124, TotalSupply: 4_788_606_639} - if len(listing) != 1 || listing[0] != want { + IcoDate: new(mustParseDate(t, "2017-11-09")), CirculatingSupply: new(4_232_705_124.0), TotalSupply: new(4_788_606_639.0)} + if len(listing) != 1 || !reflect.DeepEqual(listing[0], want) { t.Fatalf("cryptocurrency_list = %+v, want %+v", listing, want) } @@ -103,8 +105,8 @@ func TestCryptoChartFixturesDecodeExactValues(t *testing.T) { // Mirrors assert_required_non_null in // crates/libfmp/tests/asset_catalog_quote_responses.rs for the one model -// the crypto domain owns: every member is required and non-null, and an -// unknown member is accepted. +// the crypto domain owns: every member is required, every member but the +// nullable ones is non-null, and an unknown member is accepted. func TestCryptocurrencyListingRequiredMembersAreEnforcedLikeSerde(t *testing.T) { t.Parallel() var rows []map[string]jsontext.Value @@ -113,11 +115,15 @@ func TestCryptocurrencyListingRequiredMembersAreEnforcedLikeSerde(t *testing.T) } row := rows[0] members := []string{"symbol", "name", "exchange", "icoDate", "circulatingSupply", "totalSupply"} + nullable := []string{"icoDate", "circulatingSupply", "totalSupply"} for _, member := range members { for variant, mutate := range map[string]func(map[string]jsontext.Value){ "missing": func(m map[string]jsontext.Value) { delete(m, member) }, "null": func(m map[string]jsontext.Value) { m[member] = jsontext.Value("null") }, } { + if variant == "null" && slices.Contains(nullable, member) { + continue + } t.Run(member+" "+variant, func(t *testing.T) { t.Parallel() mutated := make(map[string]jsontext.Value, len(row)) @@ -160,3 +166,28 @@ func TestCryptocurrencyListingRequiredMembersAreEnforcedLikeSerde(t *testing.T) t.Fatalf("empty array: rows = %#v, err = %v, want a non-nil empty slice", empty, err) } } + +// Mirrors crypto_listing_supplies_and_ico_date_are_required_but_nullable in +// crates/libfmp/tests/asset_catalog_quote_responses.rs: a null supply or +// ICO date decodes to nil, and an empty ICO date is absent too. +func TestCryptocurrencyListingNullableMembersDecodeToNil(t *testing.T) { + t.Parallel() + const fixture = "cryptocurrency_list.json" + cases := []struct { + member string + value jsontext.Value + isNil func(CryptocurrencyListing) bool + }{ + {"icoDate", jsontext.Value(`null`), func(row CryptocurrencyListing) bool { return row.IcoDate == nil }}, + {"icoDate", jsontext.Value(`""`), func(row CryptocurrencyListing) bool { return row.IcoDate == nil }}, + {"circulatingSupply", jsontext.Value(`null`), func(row CryptocurrencyListing) bool { return row.CirculatingSupply == nil }}, + {"totalSupply", jsontext.Value(`null`), func(row CryptocurrencyListing) bool { return row.TotalSupply == nil }}, + } + for _, tc := range cases { + var rows []CryptocurrencyListing + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, tc.value), &rows); err != nil || + len(rows) != 1 || !tc.isNil(rows[0]) { + t.Fatalf("%s = %s: %+v, %v, want nil", tc.member, tc.value, rows, err) + } + } +} diff --git a/sdk/go/crypto_test.go b/sdk/go/crypto_test.go index e14cd829..b91e7e2c 100644 --- a/sdk/go/crypto_test.go +++ b/sdk/go/crypto_test.go @@ -52,7 +52,7 @@ func TestCryptoMethodsUseExactPathsAndQueryOrder(t *testing.T) { from, to := mustParseDate(t, "2024-01-01"), mustParseDate(t, "2024-03-01") listing, err := client.Crypto.List(ctx) - if err != nil || len(listing) != 1 || listing[0].Symbol != "MIOTAUSD" || listing[0].TotalSupply != 4_788_606_639 { + if err != nil || len(listing) != 1 || listing[0].Symbol != "MIOTAUSD" || listing[0].TotalSupply == nil || *listing[0].TotalSupply != 4_788_606_639 { t.Fatalf("List = %+v, %v", listing, err) } quote, err := client.Crypto.Quote(ctx, NewQuoteQuery("BTCUSD")) diff --git a/sdk/go/integral_f64_parity_test.go b/sdk/go/integral_f64_parity_test.go index 020b1126..786a4c51 100644 --- a/sdk/go/integral_f64_parity_test.go +++ b/sdk/go/integral_f64_parity_test.go @@ -2,6 +2,7 @@ package fmp import ( "encoding/json/v2" + "reflect" "testing" ) @@ -20,8 +21,8 @@ func TestIntegralFloatFieldsDecodeFractionalAndExponentForms(t *testing.T) { } crypto := assertFixtureParity[CryptocurrencyListing](t, "cryptocurrency_list_fractional_synthetic.json") - if len(crypto) != 1 || crypto[0].CirculatingSupply != 4_232_705_124.5 || - crypto[0].TotalSupply != 4_788_606_639 { + if len(crypto) != 1 || !reflect.DeepEqual(crypto[0].CirculatingSupply, new(4_232_705_124.5)) || + !reflect.DeepEqual(crypto[0].TotalSupply, new(4_788_606_639.0)) { t.Fatalf("cryptocurrency_list_fractional_synthetic = %+v", crypto) } @@ -37,7 +38,8 @@ func TestIntegralFloatFieldsDecodeExponentForms(t *testing.T) { var crypto CryptocurrencyListing err := json.Unmarshal([]byte(`{"symbol":"MIOTAUSD","name":"IOTA USD","exchange":"CCC","icoDate":"2017-11-09",`+ `"circulatingSupply":4.2327051245e9,"totalSupply":4.788606639e9}`), &crypto) - if err != nil || crypto.CirculatingSupply != 4_232_705_124.5 || crypto.TotalSupply != 4_788_606_639 { + if err != nil || !reflect.DeepEqual(crypto.CirculatingSupply, new(4_232_705_124.5)) || + !reflect.DeepEqual(crypto.TotalSupply, new(4_788_606_639.0)) { t.Fatalf("CryptocurrencyListing = %+v, %v", crypto, err) } From d3046a935af79cadeb957e6951f9f7b4b07ba60b Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 21:36:16 +0800 Subject: [PATCH 3/3] feat(responses): make omittable fundraising and sec filings members optional Members that FMP can send as null on older filings are now decoded as optional instead of required, so callers must handle the absent case. **Model changes:** * `CrowdfundingOffering` makes `issuerWebsite`, `intermediaryCompanyName`, `intermediaryCommissionFileNumber`, `compensationAmount`, `financialInterest`, `securityOfferedType`, `overSubscriptionAllocationType`, and `offeringDeadlineDate` optional. * `RegulationDOffering` makes `revenueRange` and `securitiesOfferedAreOfEquityType` optional. * `SecCompanyProfile` makes `fiscalYearEnd` and `employees` optional. * `SecCompanySearchResult` makes `phoneNumber` optional. **Decoding behaviour:** * libfmp uses `required_option` so these members must be present but decode a JSON null to `None`; the Go SDK mirrors this with `jsontext.Value` shadows that reject a missing member and map null to nil. * The Python `.pyi` stubs and pyo3 models expose the new `Optional` types, and the typecheck contract now treats `offering_deadline_date` as nullable. **Tests:** * Added libfmp, Go parity, and Python tests asserting each new nullable member decodes null to None/nil while a missing member still fails. BREAKING CHANGE: CrowdfundingOffering, RegulationDOffering, SecCompanyProfile and SecCompanySearchResult members FMP can omit are now optional (Option / pointer / Optional); handle None. --- .../fmp/_native/fundraising/__init__.pyi | 42 ++--- .../fmp/_native/sec_filings/__init__.pyi | 12 +- crates/fmp-py/src/models/fundraising.rs | 42 ++--- crates/fmp-py/src/models/sec_filings.rs | 14 +- crates/fmp-py/tests/test_fundraising_a.py | 36 ++++ crates/fmp-py/tests/test_sec_filings_b.py | 22 +++ crates/fmp-py/tests/typecheck_contract.py | 2 +- crates/libfmp/src/responses/fundraising.rs | 30 ++-- crates/libfmp/src/responses/sec_filings.rs | 9 +- .../fundraising_crowdfunding_responses.rs | 29 +++- .../fundraising_regulation_d_responses.rs | 13 +- crates/libfmp/tests/sec_filings_responses.rs | 34 +++- sdk/go/fundraising_models.go | 160 +++++++++++++----- sdk/go/fundraising_parity_test.go | 67 +++++++- sdk/go/sec_filings_models.go | 72 +++++--- sdk/go/sec_filings_parity_test.go | 66 +++++++- sdk/go/sec_filings_test.go | 3 +- 17 files changed, 503 insertions(+), 150 deletions(-) diff --git a/crates/fmp-py/python/fmp/_native/fundraising/__init__.pyi b/crates/fmp-py/python/fmp/_native/fundraising/__init__.pyi index 29ed089f..d6b14b17 100644 --- a/crates/fmp-py/python/fmp/_native/fundraising/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/fundraising/__init__.pyi @@ -96,19 +96,19 @@ class CrowdfundingOffering: @property def issuer_zip_code(self) -> builtins.str: ... @property - def issuer_website(self) -> builtins.str: ... + def issuer_website(self) -> typing.Optional[builtins.str]: ... @property - def intermediary_company_name(self) -> builtins.str: ... + def intermediary_company_name(self) -> typing.Optional[builtins.str]: ... @property def intermediary_commission_cik(self) -> builtins.str: ... @property - def intermediary_commission_file_number(self) -> builtins.str: ... + def intermediary_commission_file_number(self) -> typing.Optional[builtins.str]: ... @property - def compensation_amount(self) -> builtins.str: ... + def compensation_amount(self) -> typing.Optional[builtins.str]: ... @property - def financial_interest(self) -> builtins.str: ... + def financial_interest(self) -> typing.Optional[builtins.str]: ... @property - def security_offered_type(self) -> builtins.str: ... + def security_offered_type(self) -> typing.Optional[builtins.str]: ... @property def security_offered_other_description(self) -> typing.Optional[builtins.str]: ... @property @@ -116,11 +116,11 @@ class CrowdfundingOffering: @property def offering_amount(self) -> builtins.float: ... @property - def over_subscription_allocation_type(self) -> builtins.str: ... + def over_subscription_allocation_type(self) -> typing.Optional[builtins.str]: ... @property def maximum_offering_amount(self) -> builtins.float: ... @property - def offering_deadline_date(self) -> datetime.date: ... + def offering_deadline_date(self) -> typing.Optional[datetime.date]: ... @property def current_number_of_employees(self) -> builtins.int: ... @property @@ -181,21 +181,21 @@ class CrowdfundingOffering: issuer_city: builtins.str, issuer_state_or_country: builtins.str, issuer_zip_code: builtins.str, - issuer_website: builtins.str, - intermediary_company_name: builtins.str, + issuer_website: typing.Optional[builtins.str], + intermediary_company_name: typing.Optional[builtins.str], intermediary_commission_cik: builtins.str, - intermediary_commission_file_number: builtins.str, - compensation_amount: builtins.str, - financial_interest: builtins.str, - security_offered_type: builtins.str, + intermediary_commission_file_number: typing.Optional[builtins.str], + compensation_amount: typing.Optional[builtins.str], + financial_interest: typing.Optional[builtins.str], + security_offered_type: typing.Optional[builtins.str], security_offered_other_description: typing.Optional[builtins.str], number_of_security_offered: builtins.float, offering_price: builtins.int | builtins.float, offering_amount: builtins.float, over_subscription_accepted: typing.Literal["Y", "N"], - over_subscription_allocation_type: builtins.str, + over_subscription_allocation_type: typing.Optional[builtins.str], maximum_offering_amount: builtins.float, - offering_deadline_date: datetime.date, + offering_deadline_date: typing.Optional[datetime.date], current_number_of_employees: builtins.int, total_asset_most_recent_fiscal_year: builtins.float, total_asset_prior_fiscal_year: builtins.float, @@ -415,7 +415,7 @@ class RegulationDOffering: @property def industry_group_type(self) -> builtins.str: ... @property - def revenue_range(self) -> builtins.str: ... + def revenue_range(self) -> typing.Optional[builtins.str]: ... @property def federal_exemptions_exclusions(self) -> builtins.str: ... @property @@ -425,7 +425,9 @@ class RegulationDOffering: @property def duration_of_offering_is_more_than_year(self) -> builtins.bool: ... @property - def securities_offered_are_of_equity_type(self) -> builtins.bool: ... + def securities_offered_are_of_equity_type( + self, + ) -> typing.Optional[builtins.bool]: ... @property def is_business_combination_transaction(self) -> builtins.bool: ... @property @@ -477,12 +479,12 @@ class RegulationDOffering: related_person_zip_code: builtins.str, related_person_relationship: builtins.str, industry_group_type: builtins.str, - revenue_range: builtins.str, + revenue_range: typing.Optional[builtins.str], federal_exemptions_exclusions: builtins.str, is_amendment: builtins.bool, date_of_first_sale: typing.Optional[datetime.date], duration_of_offering_is_more_than_year: builtins.bool, - securities_offered_are_of_equity_type: builtins.bool, + securities_offered_are_of_equity_type: typing.Optional[builtins.bool], is_business_combination_transaction: builtins.bool, minimum_investment_accepted: builtins.float, total_offering_amount: builtins.float, diff --git a/crates/fmp-py/python/fmp/_native/sec_filings/__init__.pyi b/crates/fmp-py/python/fmp/_native/sec_filings/__init__.pyi index a7aefca1..81f6e4cc 100644 --- a/crates/fmp-py/python/fmp/_native/sec_filings/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/sec_filings/__init__.pyi @@ -95,11 +95,11 @@ class SecCompanyProfile: @property def state_of_incorporation(self) -> builtins.str: ... @property - def fiscal_year_end(self) -> builtins.str: ... + def fiscal_year_end(self) -> typing.Optional[builtins.str]: ... @property def ipo_date(self) -> datetime.date: ... @property - def employees(self) -> builtins.str: ... + def employees(self) -> typing.Optional[builtins.str]: ... @property def sec_filings_url(self) -> builtins.str: ... @property @@ -148,9 +148,9 @@ class SecCompanyProfile: exchange: builtins.str, state_location: builtins.str, state_of_incorporation: builtins.str, - fiscal_year_end: builtins.str, + fiscal_year_end: typing.Optional[builtins.str], ipo_date: datetime.date, - employees: builtins.str, + employees: typing.Optional[builtins.str], sec_filings_url: builtins.str, tax_identification_number: builtins.str, fifty_two_week_range: builtins.str, @@ -199,7 +199,7 @@ class SecCompanySearchResult: @property def business_address(self) -> builtins.str: ... @property - def phone_number(self) -> builtins.str: ... + def phone_number(self) -> typing.Optional[builtins.str]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, @@ -210,7 +210,7 @@ class SecCompanySearchResult: sic_code: builtins.str, industry_title: builtins.str, business_address: builtins.str, - phone_number: builtins.str, + phone_number: typing.Optional[builtins.str], ) -> SecCompanySearchResult: ... def __getnewargs_ex__(self) -> tuple[tuple, dict]: ... def __repr__(self) -> builtins.str: ... diff --git a/crates/fmp-py/src/models/fundraising.rs b/crates/fmp-py/src/models/fundraising.rs index 21c34e8c..894b9930 100644 --- a/crates/fmp-py/src/models/fundraising.rs +++ b/crates/fmp-py/src/models/fundraising.rs @@ -252,19 +252,19 @@ pub(crate) struct CrowdfundingOffering { #[pyo3(get)] pub issuer_zip_code: String, #[pyo3(get)] - pub issuer_website: String, + pub issuer_website: Option, #[pyo3(get)] - pub intermediary_company_name: String, + pub intermediary_company_name: Option, #[pyo3(get)] pub intermediary_commission_cik: String, #[pyo3(get)] - pub intermediary_commission_file_number: String, + pub intermediary_commission_file_number: Option, #[pyo3(get)] - pub compensation_amount: String, + pub compensation_amount: Option, #[pyo3(get)] - pub financial_interest: String, + pub financial_interest: Option, #[pyo3(get)] - pub security_offered_type: String, + pub security_offered_type: Option, #[pyo3(get)] pub security_offered_other_description: Option, #[pyo3(get)] @@ -274,11 +274,11 @@ pub(crate) struct CrowdfundingOffering { pub offering_amount: f64, pub over_subscription_accepted: String, #[pyo3(get)] - pub over_subscription_allocation_type: String, + pub over_subscription_allocation_type: Option, #[pyo3(get)] pub maximum_offering_amount: f64, #[pyo3(get)] - pub offering_deadline_date: ::chrono::NaiveDate, + pub offering_deadline_date: Option<::chrono::NaiveDate>, #[pyo3(get)] pub current_number_of_employees: u64, #[pyo3(get)] @@ -341,13 +341,13 @@ impl CrowdfundingOffering { issuer_city: String, issuer_state_or_country: String, issuer_zip_code: String, - issuer_website: String, - intermediary_company_name: String, + issuer_website: Option, + intermediary_company_name: Option, intermediary_commission_cik: String, - intermediary_commission_file_number: String, - compensation_amount: String, - financial_interest: String, - security_offered_type: String, + intermediary_commission_file_number: Option, + compensation_amount: Option, + financial_interest: Option, + security_offered_type: Option, security_offered_other_description: Option, number_of_security_offered: f64, #[gen_stub(override_type(type_repr = "builtins.int | builtins.float", imports = ("builtins",)))] @@ -355,9 +355,9 @@ impl CrowdfundingOffering { offering_amount: f64, #[gen_stub(override_type(type_repr = "typing.Literal[\"Y\", \"N\"]", imports = ("builtins", "typing")))] over_subscription_accepted: String, - over_subscription_allocation_type: String, + over_subscription_allocation_type: Option, maximum_offering_amount: f64, - offering_deadline_date: ::chrono::NaiveDate, + offering_deadline_date: Option<::chrono::NaiveDate>, current_number_of_employees: u64, total_asset_most_recent_fiscal_year: f64, total_asset_prior_fiscal_year: f64, @@ -1269,7 +1269,7 @@ impl From for Crowdfunding over_subscription_accepted: value.over_subscription_accepted.to_string(), over_subscription_allocation_type: value.over_subscription_allocation_type, maximum_offering_amount: value.maximum_offering_amount, - offering_deadline_date: value.offering_deadline_date.into_inner(), + offering_deadline_date: value.offering_deadline_date.map(|value| value.into_inner()), current_number_of_employees: value.current_number_of_employees, total_asset_most_recent_fiscal_year: value.total_asset_most_recent_fiscal_year, total_asset_prior_fiscal_year: value.total_asset_prior_fiscal_year, @@ -1361,7 +1361,7 @@ pub(crate) struct RegulationDOffering { #[pyo3(get)] pub industry_group_type: String, #[pyo3(get)] - pub revenue_range: String, + pub revenue_range: Option, #[pyo3(get)] pub federal_exemptions_exclusions: String, #[pyo3(get)] @@ -1371,7 +1371,7 @@ pub(crate) struct RegulationDOffering { #[pyo3(get)] pub duration_of_offering_is_more_than_year: bool, #[pyo3(get)] - pub securities_offered_are_of_equity_type: bool, + pub securities_offered_are_of_equity_type: Option, #[pyo3(get)] pub is_business_combination_transaction: bool, #[pyo3(get)] @@ -1429,12 +1429,12 @@ impl RegulationDOffering { related_person_zip_code: String, related_person_relationship: String, industry_group_type: String, - revenue_range: String, + revenue_range: Option, federal_exemptions_exclusions: String, is_amendment: bool, date_of_first_sale: Option<::chrono::NaiveDate>, duration_of_offering_is_more_than_year: bool, - securities_offered_are_of_equity_type: bool, + securities_offered_are_of_equity_type: Option, is_business_combination_transaction: bool, minimum_investment_accepted: f64, total_offering_amount: f64, diff --git a/crates/fmp-py/src/models/sec_filings.rs b/crates/fmp-py/src/models/sec_filings.rs index c358b246..0b4de20e 100644 --- a/crates/fmp-py/src/models/sec_filings.rs +++ b/crates/fmp-py/src/models/sec_filings.rs @@ -207,7 +207,7 @@ pub(crate) struct SecCompanySearchResult { #[pyo3(get)] pub business_address: String, #[pyo3(get)] - pub phone_number: String, + pub phone_number: Option, } #[gen_stub_pymethods] @@ -224,7 +224,7 @@ impl SecCompanySearchResult { sic_code: String, industry_title: String, business_address: String, - phone_number: String, + phone_number: Option, ) -> Self { Self { symbol, @@ -408,11 +408,11 @@ pub(crate) struct SecCompanyProfile { #[pyo3(get)] pub state_of_incorporation: String, #[pyo3(get)] - pub fiscal_year_end: String, + pub fiscal_year_end: Option, #[pyo3(get)] pub ipo_date: ::chrono::NaiveDate, #[pyo3(get)] - pub employees: String, + pub employees: Option, #[pyo3(get)] pub sec_filings_url: String, #[pyo3(get)] @@ -466,9 +466,9 @@ impl SecCompanyProfile { exchange: String, state_location: String, state_of_incorporation: String, - fiscal_year_end: String, + fiscal_year_end: Option, ipo_date: ::chrono::NaiveDate, - employees: String, + employees: Option, sec_filings_url: String, tax_identification_number: String, fifty_two_week_range: String, @@ -957,7 +957,7 @@ impl From for SecCompanyProfi state_of_incorporation: value.state_of_incorporation, fiscal_year_end: value.fiscal_year_end, ipo_date: value.ipo_date.into_inner(), - employees: value.employees.into_inner(), + employees: value.employees.map(|value| value.into_inner()), sec_filings_url: value.sec_filings_url, tax_identification_number: value.tax_identification_number, fifty_two_week_range: value.fifty_two_week_range, diff --git a/crates/fmp-py/tests/test_fundraising_a.py b/crates/fmp-py/tests/test_fundraising_a.py index dfaeb8e8..dcd6d278 100644 --- a/crates/fmp-py/tests/test_fundraising_a.py +++ b/crates/fmp-py/tests/test_fundraising_a.py @@ -272,3 +272,39 @@ def test_regulation_d_offerings_decode_fractional_amounts(client: Any, fixture_s assert isinstance(rows[0].total_offering_amount, float) assert rows[0].total_offering_amount == 71_999_990.5 assert rows[0].total_amount_sold == 1.0 + + +def test_latest_crowdfunding_offerings_decode_null_members_to_none(client: Any, fixture_server: FixtureServer) -> None: + """The members FMP sends as null on older filings decode to ``None``.""" + body = load_fixture("crowdfunding_offerings_latest.json") + body[0]["compensationAmount"] = None + body[0]["financialInterest"] = None + body[0]["intermediaryCommissionFileNumber"] = None + body[0]["intermediaryCompanyName"] = None + body[0]["issuerWebsite"] = None + body[0]["offeringDeadlineDate"] = None + body[0]["overSubscriptionAllocationType"] = None + body[0]["securityOfferedType"] = None + fixture_server.route("/crowdfunding-offerings-latest", body) + rows = client.fundraising.latest_crowdfunding_offerings() + + assert rows[0].compensation_amount is None + assert rows[0].financial_interest is None + assert rows[0].intermediary_commission_file_number is None + assert rows[0].intermediary_company_name is None + assert rows[0].issuer_website is None + assert rows[0].offering_deadline_date is None + assert rows[0].over_subscription_allocation_type is None + assert rows[0].security_offered_type is None + + +def test_latest_regulation_d_offerings_decode_null_members_to_none(client: Any, fixture_server: FixtureServer) -> None: + """A null ``revenueRange`` or ``securitiesOfferedAreOfEquityType`` decodes to ``None``.""" + body = load_fixture("fundraising_latest.json") + body[0]["revenueRange"] = None + body[0]["securitiesOfferedAreOfEquityType"] = None + fixture_server.route("/fundraising-latest", body) + rows = client.fundraising.latest_regulation_d_offerings() + + assert rows[0].revenue_range is None + assert rows[0].securities_offered_are_of_equity_type is None diff --git a/crates/fmp-py/tests/test_sec_filings_b.py b/crates/fmp-py/tests/test_sec_filings_b.py index 3b4ced94..77ac5be7 100644 --- a/crates/fmp-py/tests/test_sec_filings_b.py +++ b/crates/fmp-py/tests/test_sec_filings_b.py @@ -301,3 +301,25 @@ def test_non_json_body_is_a_decode_error(client: Any, fixture_server: FixtureSer fixture_server.route("/sec-profile", b"not-json") with pytest.raises(errors.FmpDecodeError): client.sec_filings.company_profile("AAPL") + + +def test_company_profile_decodes_null_employees_and_fiscal_year_end_to_none(client: Any, fixture_server: FixtureServer) -> None: + """A fund profile with null ``employees`` and ``fiscalYearEnd`` decodes them to ``None``.""" + body = load_fixture("sec_company_profile.json") + body[0]["employees"] = None + body[0]["fiscalYearEnd"] = None + fixture_server.route("/sec-profile", body) + rows = client.sec_filings.company_profile("AAPL") + + assert rows[0].employees is None + assert rows[0].fiscal_year_end is None + + +def test_search_companies_by_name_decodes_null_phone_number_to_none(client: Any, fixture_server: FixtureServer) -> None: + """A null ``phoneNumber`` decodes to ``None``.""" + body = load_fixture("sec_companies_by_name.json") + body[0]["phoneNumber"] = None + fixture_server.route("/sec-filings-company-search/name", body) + rows = client.sec_filings.search_companies_by_name("Berkshire") + + assert rows[0].phone_number is None diff --git a/crates/fmp-py/tests/typecheck_contract.py b/crates/fmp-py/tests/typecheck_contract.py index 57b77b69..a438c6a7 100644 --- a/crates/fmp-py/tests/typecheck_contract.py +++ b/crates/fmp-py/tests/typecheck_contract.py @@ -501,7 +501,7 @@ def check_fundraising_contract(client: FmpClient) -> None: """Type-check the flat fundraising namespace, its keyword-only filters, and the nullable date rows.""" fundraising: FundraisingNamespace = client.fundraising crowdfunding: list[CrowdfundingOffering] = fundraising.latest_crowdfunding_offerings(page=0, limit=100) - deadline: datetime.date = crowdfunding[0].offering_deadline_date + deadline: datetime.date | None = crowdfunding[0].offering_deadline_date accepted: datetime.datetime = crowdfunding[0].accepted_date price: Any = crowdfunding[0].offering_price by_cik: list[CrowdfundingOffering] = client.fundraising.crowdfunding_offerings_by_cik("0001916078") diff --git a/crates/libfmp/src/responses/fundraising.rs b/crates/libfmp/src/responses/fundraising.rs index 76401dc9..4f727ea5 100644 --- a/crates/libfmp/src/responses/fundraising.rs +++ b/crates/libfmp/src/responses/fundraising.rs @@ -67,13 +67,19 @@ pub struct CrowdfundingOffering { pub issuer_city: String, pub issuer_state_or_country: String, pub issuer_zip_code: String, - pub issuer_website: String, - pub intermediary_company_name: String, + #[serde(deserialize_with = "required_option")] + pub issuer_website: Option, + #[serde(deserialize_with = "required_option")] + pub intermediary_company_name: Option, pub intermediary_commission_cik: Cik, - pub intermediary_commission_file_number: String, - pub compensation_amount: String, - pub financial_interest: String, - pub security_offered_type: String, + #[serde(deserialize_with = "required_option")] + pub intermediary_commission_file_number: Option, + #[serde(deserialize_with = "required_option")] + pub compensation_amount: Option, + #[serde(deserialize_with = "required_option")] + pub financial_interest: Option, + #[serde(deserialize_with = "required_option")] + pub security_offered_type: Option, #[serde(deserialize_with = "required_option")] pub security_offered_other_description: Option, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] @@ -82,10 +88,12 @@ pub struct CrowdfundingOffering { #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub offering_amount: StatementAmount, pub over_subscription_accepted: YnFlag, - pub over_subscription_allocation_type: String, + #[serde(deserialize_with = "required_option")] + pub over_subscription_allocation_type: Option, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub maximum_offering_amount: StatementAmount, - pub offering_deadline_date: UsDate, + #[serde(deserialize_with = "required_option")] + pub offering_deadline_date: Option, #[serde(deserialize_with = "crate::codecs::count::deserialize")] pub current_number_of_employees: Count, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] @@ -166,13 +174,15 @@ pub struct RegulationDOffering { pub related_person_zip_code: String, pub related_person_relationship: String, pub industry_group_type: String, - pub revenue_range: String, + #[serde(deserialize_with = "required_option")] + pub revenue_range: Option, pub federal_exemptions_exclusions: String, pub is_amendment: bool, #[serde(with = "empty_date")] pub date_of_first_sale: Option, pub duration_of_offering_is_more_than_year: bool, - pub securities_offered_are_of_equity_type: bool, + #[serde(deserialize_with = "required_option")] + pub securities_offered_are_of_equity_type: Option, pub is_business_combination_transaction: bool, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] pub minimum_investment_accepted: MarketValue, diff --git a/crates/libfmp/src/responses/sec_filings.rs b/crates/libfmp/src/responses/sec_filings.rs index 9841c3b6..f0e1f65d 100644 --- a/crates/libfmp/src/responses/sec_filings.rs +++ b/crates/libfmp/src/responses/sec_filings.rs @@ -41,7 +41,8 @@ pub struct SecCompanySearchResult { pub sic_code: String, pub industry_title: String, pub business_address: String, - pub phone_number: String, + #[serde(deserialize_with = "required_option")] + pub phone_number: Option, } /// One full SEC company profile. @@ -69,9 +70,11 @@ pub struct SecCompanyProfile { pub exchange: ExchangeCode, pub state_location: String, pub state_of_incorporation: String, - pub fiscal_year_end: String, + #[serde(deserialize_with = "required_option")] + pub fiscal_year_end: Option, pub ipo_date: Date, - pub employees: NumericString, + #[serde(deserialize_with = "required_option")] + pub employees: Option, pub sec_filings_url: String, pub tax_identification_number: String, pub fifty_two_week_range: String, diff --git a/crates/libfmp/tests/fundraising_crowdfunding_responses.rs b/crates/libfmp/tests/fundraising_crowdfunding_responses.rs index 1d85e71a..ce4b8727 100644 --- a/crates/libfmp/tests/fundraising_crowdfunding_responses.rs +++ b/crates/libfmp/tests/fundraising_crowdfunding_responses.rs @@ -5,6 +5,17 @@ use libfmp::{codecs::YnFlag, responses::fundraising::CrowdfundingOffering}; const LATEST: &[u8] = include_bytes!("fixtures/crowdfunding_offerings_latest.json"); const BY_CIK: &[u8] = include_bytes!("fixtures/crowdfunding_offerings_by_cik.json"); +const NULLABLE: [&str; 9] = [ + "securityOfferedOtherDescription", + "compensationAmount", + "financialInterest", + "intermediaryCommissionFileNumber", + "intermediaryCompanyName", + "issuerWebsite", + "offeringDeadlineDate", + "overSubscriptionAllocationType", + "securityOfferedType", +]; #[test] fn both_exact_outer_md_fixtures_round_trip_as_the_shared_48_key_row() { @@ -33,7 +44,13 @@ fn typed_fields_preserve_dates_ciks_flags_prices_counts_and_signed_financials() assert_eq!(latest.date.to_string(), "11-22-2011"); assert_eq!(latest.filing_date.to_string(), "2026-07-30 00:00:00"); assert_eq!(latest.accepted_date.to_string(), "2026-07-30 12:54:38"); - assert_eq!(latest.offering_deadline_date.to_string(), "10-31-2026"); + assert_eq!( + latest + .offering_deadline_date + .map(|date| date.to_string()) + .as_deref(), + Some("10-31-2026") + ); assert_eq!(latest.form_type.as_str(), "C/A"); assert_eq!(latest.over_subscription_accepted, YnFlag::True); assert_eq!(latest.number_of_security_offered, 100_000.0); @@ -56,7 +73,7 @@ fn typed_fields_preserve_dates_ciks_flags_prices_counts_and_signed_financials() } #[test] -fn security_description_is_required_present_nullable_and_all_other_keys_are_non_null() { +fn nullable_members_are_required_present_and_all_other_keys_are_non_null() { for fixture in [LATEST, BY_CIK] { let source: Value = serde_json::from_slice(fixture).unwrap(); let row = source[0].as_object().unwrap(); @@ -71,10 +88,10 @@ fn security_description_is_required_present_nullable_and_all_other_keys_are_non_ let mut null = row.clone(); null.insert(field.clone(), Value::Null); - if field == "securityOfferedOtherDescription" { - assert!( - serde_json::from_value::(Value::Object(null)).is_ok() - ); + if NULLABLE.contains(&field.as_str()) { + let decoded = + serde_json::from_value::(Value::Object(null)).unwrap(); + assert!(serde_json::to_value(decoded).unwrap()[field].is_null()); } else { assert!( serde_json::from_value::(Value::Object(null)).is_err(), diff --git a/crates/libfmp/tests/fundraising_regulation_d_responses.rs b/crates/libfmp/tests/fundraising_regulation_d_responses.rs index d6e31267..b57ffa1b 100644 --- a/crates/libfmp/tests/fundraising_regulation_d_responses.rs +++ b/crates/libfmp/tests/fundraising_regulation_d_responses.rs @@ -5,6 +5,11 @@ use libfmp::responses::fundraising::RegulationDOffering; const LATEST: &[u8] = include_bytes!("fixtures/fundraising_latest.json"); const BY_CIK: &[u8] = include_bytes!("fixtures/fundraising_by_cik.json"); +const NULLABLE: [&str; 3] = [ + "incorporatedWithinFiveYears", + "revenueRange", + "securitiesOfferedAreOfEquityType", +]; #[test] fn both_exact_outer_md_fixtures_round_trip_as_the_shared_43_key_row() { @@ -45,7 +50,7 @@ fn typed_fields_preserve_ciks_temporals_empty_sentinels_and_amounts() { } #[test] -fn incorporated_flag_is_required_present_nullable_and_every_other_key_is_non_null() { +fn nullable_members_are_required_present_and_every_other_key_is_non_null() { for fixture in [LATEST, BY_CIK] { let source: Value = serde_json::from_slice(fixture).unwrap(); let row = source[0].as_object().unwrap(); @@ -60,8 +65,10 @@ fn incorporated_flag_is_required_present_nullable_and_every_other_key_is_non_nul let mut null = row.clone(); null.insert(field.clone(), Value::Null); - if field == "incorporatedWithinFiveYears" { - assert!(serde_json::from_value::(Value::Object(null)).is_ok()); + if NULLABLE.contains(&field.as_str()) { + let decoded = + serde_json::from_value::(Value::Object(null)).unwrap(); + assert!(serde_json::to_value(decoded).unwrap()[field].is_null()); } else { assert!( serde_json::from_value::(Value::Object(null)).is_err(), diff --git a/crates/libfmp/tests/sec_filings_responses.rs b/crates/libfmp/tests/sec_filings_responses.rs index 4762be91..a738acea 100644 --- a/crates/libfmp/tests/sec_filings_responses.rs +++ b/crates/libfmp/tests/sec_filings_responses.rs @@ -103,7 +103,7 @@ fn full_profile_decodes_exactly_thirty_five_fields_and_documented_types() { assert_eq!(row.country, CountryCode::new("US").unwrap()); assert_eq!(row.exchange, ExchangeCode::new("NASDAQ").unwrap()); assert_eq!(row.ipo_date, Date::parse("1980-12-12").unwrap()); - assert_eq!(row.employees, NumericString::new("166000").unwrap()); + assert_eq!(row.employees, Some(NumericString::new("166000").unwrap())); assert_eq!(row.price_currency, CurrencyCode::new("USD").unwrap()); assert_eq!(row.market_sector, Sector::new("Technology").unwrap()); assert_eq!(row.security_type, None); @@ -164,9 +164,12 @@ fn typed_contracts_require_documented_non_null_fields_and_accept_unknown_fields( COMPANIES_CIK, ALL_CLASSIFICATIONS, ] { - assert_contract_except::(fixture, &[]); + assert_contract_except::(fixture, &["phoneNumber"]); } - assert_contract_except::(PROFILE, &["securityType"]); + assert_contract_except::( + PROFILE, + &["securityType", "employees", "fiscalYearEnd"], + ); assert_contract_except::(CLASSIFICATIONS, &[]); let mut profile: serde_json::Value = serde_json::from_slice(PROFILE).unwrap(); @@ -193,6 +196,31 @@ fn every_contract_is_a_bare_array_accepting_empty_and_multiple_rows() { assert!(serde_json::from_value::>(serde_json::json!({"data": []})).is_err()); } +#[test] +fn nullable_profile_and_search_members_are_required_and_null_decodes_to_none() { + for field in ["employees", "fiscalYearEnd", "securityType"] { + let mut missing: serde_json::Value = serde_json::from_slice(PROFILE).unwrap(); + missing[0].as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::>(missing).is_err()); + } + let mut profile: serde_json::Value = serde_json::from_slice(PROFILE).unwrap(); + profile[0]["employees"] = serde_json::Value::Null; + profile[0]["fiscalYearEnd"] = serde_json::Value::Null; + let rows: Vec = serde_json::from_value(profile.clone()).unwrap(); + assert_eq!(rows[0].employees, None); + assert_eq!(rows[0].fiscal_year_end, None); + assert_eq!(serde_json::to_value(&rows).unwrap(), profile); + + let mut missing: serde_json::Value = serde_json::from_slice(COMPANIES_NAME).unwrap(); + missing[0].as_object_mut().unwrap().remove("phoneNumber"); + assert!(serde_json::from_value::>(missing).is_err()); + let mut search: serde_json::Value = serde_json::from_slice(COMPANIES_NAME).unwrap(); + search[0]["phoneNumber"] = serde_json::Value::Null; + let rows: Vec = serde_json::from_value(search.clone()).unwrap(); + assert_eq!(rows[0].phone_number, None); + assert_eq!(serde_json::to_value(&rows).unwrap(), search); +} + fn source_row(fixture: &[u8]) -> serde_json::Value { serde_json::from_slice::(fixture).unwrap()[0].clone() } diff --git a/sdk/go/fundraising_models.go b/sdk/go/fundraising_models.go index 6cdfd79b..5a476f9f 100644 --- a/sdk/go/fundraising_models.go +++ b/sdk/go/fundraising_models.go @@ -116,21 +116,21 @@ type CrowdfundingOffering struct { IssuerCity string `json:"issuerCity"` IssuerStateOrCountry string `json:"issuerStateOrCountry"` IssuerZipCode string `json:"issuerZipCode"` - IssuerWebsite string `json:"issuerWebsite"` - IntermediaryCompanyName string `json:"intermediaryCompanyName"` + IssuerWebsite *string `json:"issuerWebsite"` + IntermediaryCompanyName *string `json:"intermediaryCompanyName"` IntermediaryCommissionCIK string `json:"intermediaryCommissionCik"` - IntermediaryCommissionFileNumber string `json:"intermediaryCommissionFileNumber"` - CompensationAmount string `json:"compensationAmount"` - FinancialInterest string `json:"financialInterest"` - SecurityOfferedType string `json:"securityOfferedType"` + IntermediaryCommissionFileNumber *string `json:"intermediaryCommissionFileNumber"` + CompensationAmount *string `json:"compensationAmount"` + FinancialInterest *string `json:"financialInterest"` + SecurityOfferedType *string `json:"securityOfferedType"` SecurityOfferedOtherDescription *string `json:"securityOfferedOtherDescription"` NumberOfSecurityOffered float64 `json:"numberOfSecurityOffered"` OfferingPrice jsontext.Value `json:"offeringPrice"` OfferingAmount float64 `json:"offeringAmount"` OverSubscriptionAccepted string `json:"overSubscriptionAccepted"` - OverSubscriptionAllocationType string `json:"overSubscriptionAllocationType"` + OverSubscriptionAllocationType *string `json:"overSubscriptionAllocationType"` MaximumOfferingAmount float64 `json:"maximumOfferingAmount"` - OfferingDeadlineDate USDate `json:"offeringDeadlineDate"` + OfferingDeadlineDate *USDate `json:"offeringDeadlineDate"` CurrentNumberOfEmployees uint64 `json:"currentNumberOfEmployees"` TotalAssetMostRecentFiscalYear float64 `json:"totalAssetMostRecentFiscalYear"` TotalAssetPriorFiscalYear float64 `json:"totalAssetPriorFiscalYear"` @@ -170,21 +170,21 @@ type crowdfundingOfferingShadow struct { IssuerCity *string `json:"issuerCity"` IssuerStateOrCountry *string `json:"issuerStateOrCountry"` IssuerZipCode *string `json:"issuerZipCode"` - IssuerWebsite *string `json:"issuerWebsite"` - IntermediaryCompanyName *string `json:"intermediaryCompanyName"` + IssuerWebsite jsontext.Value `json:"issuerWebsite"` + IntermediaryCompanyName jsontext.Value `json:"intermediaryCompanyName"` IntermediaryCommissionCIK *string `json:"intermediaryCommissionCik"` - IntermediaryCommissionFileNumber *string `json:"intermediaryCommissionFileNumber"` - CompensationAmount *string `json:"compensationAmount"` - FinancialInterest *string `json:"financialInterest"` - SecurityOfferedType *string `json:"securityOfferedType"` + IntermediaryCommissionFileNumber jsontext.Value `json:"intermediaryCommissionFileNumber"` + CompensationAmount jsontext.Value `json:"compensationAmount"` + FinancialInterest jsontext.Value `json:"financialInterest"` + SecurityOfferedType jsontext.Value `json:"securityOfferedType"` SecurityOfferedOtherDescription jsontext.Value `json:"securityOfferedOtherDescription"` NumberOfSecurityOffered *float64 `json:"numberOfSecurityOffered"` OfferingPrice *jsontext.Value `json:"offeringPrice"` OfferingAmount *float64 `json:"offeringAmount"` OverSubscriptionAccepted *string `json:"overSubscriptionAccepted"` - OverSubscriptionAllocationType *string `json:"overSubscriptionAllocationType"` + OverSubscriptionAllocationType jsontext.Value `json:"overSubscriptionAllocationType"` MaximumOfferingAmount *float64 `json:"maximumOfferingAmount"` - OfferingDeadlineDate *USDate `json:"offeringDeadlineDate"` + OfferingDeadlineDate jsontext.Value `json:"offeringDeadlineDate"` CurrentNumberOfEmployees jsontext.Value `json:"currentNumberOfEmployees"` TotalAssetMostRecentFiscalYear *float64 `json:"totalAssetMostRecentFiscalYear"` TotalAssetPriorFiscalYear *float64 `json:"totalAssetPriorFiscalYear"` @@ -243,19 +243,19 @@ func (m *CrowdfundingOffering) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("CrowdfundingOffering", "issuerStateOrCountry") case shadow.IssuerZipCode == nil: return missingMemberError("CrowdfundingOffering", "issuerZipCode") - case shadow.IssuerWebsite == nil: + case len(shadow.IssuerWebsite) == 0: return missingMemberError("CrowdfundingOffering", "issuerWebsite") - case shadow.IntermediaryCompanyName == nil: + case len(shadow.IntermediaryCompanyName) == 0: return missingMemberError("CrowdfundingOffering", "intermediaryCompanyName") case shadow.IntermediaryCommissionCIK == nil: return missingMemberError("CrowdfundingOffering", "intermediaryCommissionCik") - case shadow.IntermediaryCommissionFileNumber == nil: + case len(shadow.IntermediaryCommissionFileNumber) == 0: return missingMemberError("CrowdfundingOffering", "intermediaryCommissionFileNumber") - case shadow.CompensationAmount == nil: + case len(shadow.CompensationAmount) == 0: return missingMemberError("CrowdfundingOffering", "compensationAmount") - case shadow.FinancialInterest == nil: + case len(shadow.FinancialInterest) == 0: return missingMemberError("CrowdfundingOffering", "financialInterest") - case shadow.SecurityOfferedType == nil: + case len(shadow.SecurityOfferedType) == 0: return missingMemberError("CrowdfundingOffering", "securityOfferedType") case len(shadow.SecurityOfferedOtherDescription) == 0: return missingMemberError("CrowdfundingOffering", "securityOfferedOtherDescription") @@ -267,11 +267,11 @@ func (m *CrowdfundingOffering) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("CrowdfundingOffering", "offeringAmount") case shadow.OverSubscriptionAccepted == nil: return missingMemberError("CrowdfundingOffering", "overSubscriptionAccepted") - case shadow.OverSubscriptionAllocationType == nil: + case len(shadow.OverSubscriptionAllocationType) == 0: return missingMemberError("CrowdfundingOffering", "overSubscriptionAllocationType") case shadow.MaximumOfferingAmount == nil: return missingMemberError("CrowdfundingOffering", "maximumOfferingAmount") - case shadow.OfferingDeadlineDate == nil: + case len(shadow.OfferingDeadlineDate) == 0: return missingMemberError("CrowdfundingOffering", "offeringDeadlineDate") case len(shadow.CurrentNumberOfEmployees) == 0: return missingMemberError("CrowdfundingOffering", "currentNumberOfEmployees") @@ -312,6 +312,54 @@ func (m *CrowdfundingOffering) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.NetIncomePriorFiscalYear == nil: return missingMemberError("CrowdfundingOffering", "netIncomePriorFiscalYear") } + var issuerWebsite *string + if shadow.IssuerWebsite.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.IssuerWebsite, &value); err != nil { + return err + } + issuerWebsite = &value + } + var intermediaryCompanyName *string + if shadow.IntermediaryCompanyName.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.IntermediaryCompanyName, &value); err != nil { + return err + } + intermediaryCompanyName = &value + } + var intermediaryCommissionFileNumber *string + if shadow.IntermediaryCommissionFileNumber.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.IntermediaryCommissionFileNumber, &value); err != nil { + return err + } + intermediaryCommissionFileNumber = &value + } + var compensationAmount *string + if shadow.CompensationAmount.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.CompensationAmount, &value); err != nil { + return err + } + compensationAmount = &value + } + var financialInterest *string + if shadow.FinancialInterest.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.FinancialInterest, &value); err != nil { + return err + } + financialInterest = &value + } + var securityOfferedType *string + if shadow.SecurityOfferedType.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.SecurityOfferedType, &value); err != nil { + return err + } + securityOfferedType = &value + } var securityOfferedOtherDescription *string if shadow.SecurityOfferedOtherDescription.Kind() != 'n' { var value string @@ -323,6 +371,22 @@ func (m *CrowdfundingOffering) UnmarshalJSONFrom(dec *jsontext.Decoder) error { if shadow.OfferingPrice.Kind() != '0' { return invalidMemberError("CrowdfundingOffering", "offeringPrice", "number") } + var overSubscriptionAllocationType *string + if shadow.OverSubscriptionAllocationType.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.OverSubscriptionAllocationType, &value); err != nil { + return err + } + overSubscriptionAllocationType = &value + } + var offeringDeadlineDate *USDate + if shadow.OfferingDeadlineDate.Kind() != 'n' { + var value USDate + if err := json.Unmarshal(shadow.OfferingDeadlineDate, &value); err != nil { + return err + } + offeringDeadlineDate = &value + } currentNumberOfEmployees, err := decodeCount("CrowdfundingOffering", "currentNumberOfEmployees", shadow.CurrentNumberOfEmployees) if err != nil { return err @@ -342,21 +406,21 @@ func (m *CrowdfundingOffering) UnmarshalJSONFrom(dec *jsontext.Decoder) error { IssuerCity: *shadow.IssuerCity, IssuerStateOrCountry: *shadow.IssuerStateOrCountry, IssuerZipCode: *shadow.IssuerZipCode, - IssuerWebsite: *shadow.IssuerWebsite, - IntermediaryCompanyName: *shadow.IntermediaryCompanyName, + IssuerWebsite: issuerWebsite, + IntermediaryCompanyName: intermediaryCompanyName, IntermediaryCommissionCIK: *shadow.IntermediaryCommissionCIK, - IntermediaryCommissionFileNumber: *shadow.IntermediaryCommissionFileNumber, - CompensationAmount: *shadow.CompensationAmount, - FinancialInterest: *shadow.FinancialInterest, - SecurityOfferedType: *shadow.SecurityOfferedType, + IntermediaryCommissionFileNumber: intermediaryCommissionFileNumber, + CompensationAmount: compensationAmount, + FinancialInterest: financialInterest, + SecurityOfferedType: securityOfferedType, SecurityOfferedOtherDescription: securityOfferedOtherDescription, NumberOfSecurityOffered: *shadow.NumberOfSecurityOffered, OfferingPrice: *shadow.OfferingPrice, OfferingAmount: *shadow.OfferingAmount, OverSubscriptionAccepted: *shadow.OverSubscriptionAccepted, - OverSubscriptionAllocationType: *shadow.OverSubscriptionAllocationType, + OverSubscriptionAllocationType: overSubscriptionAllocationType, MaximumOfferingAmount: *shadow.MaximumOfferingAmount, - OfferingDeadlineDate: *shadow.OfferingDeadlineDate, + OfferingDeadlineDate: offeringDeadlineDate, CurrentNumberOfEmployees: currentNumberOfEmployees, TotalAssetMostRecentFiscalYear: *shadow.TotalAssetMostRecentFiscalYear, TotalAssetPriorFiscalYear: *shadow.TotalAssetPriorFiscalYear, @@ -409,12 +473,12 @@ type RegulationDOffering struct { RelatedPersonZipCode string `json:"relatedPersonZipCode"` RelatedPersonRelationship string `json:"relatedPersonRelationship"` IndustryGroupType string `json:"industryGroupType"` - RevenueRange string `json:"revenueRange"` + RevenueRange *string `json:"revenueRange"` FederalExemptionsExclusions string `json:"federalExemptionsExclusions"` IsAmendment bool `json:"isAmendment"` DateOfFirstSale *Date `json:"dateOfFirstSale"` DurationOfOfferingIsMoreThanYear bool `json:"durationOfOfferingIsMoreThanYear"` - SecuritiesOfferedAreOfEquityType bool `json:"securitiesOfferedAreOfEquityType"` + SecuritiesOfferedAreOfEquityType *bool `json:"securitiesOfferedAreOfEquityType"` IsBusinessCombinationTransaction bool `json:"isBusinessCombinationTransaction"` MinimumInvestmentAccepted float64 `json:"minimumInvestmentAccepted"` TotalOfferingAmount float64 `json:"totalOfferingAmount"` @@ -458,12 +522,12 @@ type regulationDOfferingShadow struct { RelatedPersonZipCode *string `json:"relatedPersonZipCode"` RelatedPersonRelationship *string `json:"relatedPersonRelationship"` IndustryGroupType *string `json:"industryGroupType"` - RevenueRange *string `json:"revenueRange"` + RevenueRange jsontext.Value `json:"revenueRange"` FederalExemptionsExclusions *string `json:"federalExemptionsExclusions"` IsAmendment *bool `json:"isAmendment"` DateOfFirstSale jsontext.Value `json:"dateOfFirstSale"` DurationOfOfferingIsMoreThanYear *bool `json:"durationOfOfferingIsMoreThanYear"` - SecuritiesOfferedAreOfEquityType *bool `json:"securitiesOfferedAreOfEquityType"` + SecuritiesOfferedAreOfEquityType jsontext.Value `json:"securitiesOfferedAreOfEquityType"` IsBusinessCombinationTransaction *bool `json:"isBusinessCombinationTransaction"` MinimumInvestmentAccepted *float64 `json:"minimumInvestmentAccepted"` TotalOfferingAmount *float64 `json:"totalOfferingAmount"` @@ -539,7 +603,7 @@ func (m *RegulationDOffering) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("RegulationDOffering", "relatedPersonRelationship") case shadow.IndustryGroupType == nil: return missingMemberError("RegulationDOffering", "industryGroupType") - case shadow.RevenueRange == nil: + case len(shadow.RevenueRange) == 0: return missingMemberError("RegulationDOffering", "revenueRange") case shadow.FederalExemptionsExclusions == nil: return missingMemberError("RegulationDOffering", "federalExemptionsExclusions") @@ -549,7 +613,7 @@ func (m *RegulationDOffering) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("RegulationDOffering", "dateOfFirstSale") case shadow.DurationOfOfferingIsMoreThanYear == nil: return missingMemberError("RegulationDOffering", "durationOfOfferingIsMoreThanYear") - case shadow.SecuritiesOfferedAreOfEquityType == nil: + case len(shadow.SecuritiesOfferedAreOfEquityType) == 0: return missingMemberError("RegulationDOffering", "securitiesOfferedAreOfEquityType") case shadow.IsBusinessCombinationTransaction == nil: return missingMemberError("RegulationDOffering", "isBusinessCombinationTransaction") @@ -580,10 +644,26 @@ func (m *RegulationDOffering) UnmarshalJSONFrom(dec *jsontext.Decoder) error { } incorporatedWithinFiveYears = &value } + var revenueRange *string + if shadow.RevenueRange.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.RevenueRange, &value); err != nil { + return err + } + revenueRange = &value + } dateOfFirstSale, err := decodeEmptyDate("RegulationDOffering", "dateOfFirstSale", shadow.DateOfFirstSale, false) if err != nil { return err } + var securitiesOfferedAreOfEquityType *bool + if shadow.SecuritiesOfferedAreOfEquityType.Kind() != 'n' { + var value bool + if err := json.Unmarshal(shadow.SecuritiesOfferedAreOfEquityType, &value); err != nil { + return err + } + securitiesOfferedAreOfEquityType = &value + } totalNumberAlreadyInvested, err := decodeCount("RegulationDOffering", "totalNumberAlreadyInvested", shadow.TotalNumberAlreadyInvested) if err != nil { return err @@ -616,12 +696,12 @@ func (m *RegulationDOffering) UnmarshalJSONFrom(dec *jsontext.Decoder) error { RelatedPersonZipCode: *shadow.RelatedPersonZipCode, RelatedPersonRelationship: *shadow.RelatedPersonRelationship, IndustryGroupType: *shadow.IndustryGroupType, - RevenueRange: *shadow.RevenueRange, + RevenueRange: revenueRange, FederalExemptionsExclusions: *shadow.FederalExemptionsExclusions, IsAmendment: *shadow.IsAmendment, DateOfFirstSale: dateOfFirstSale, DurationOfOfferingIsMoreThanYear: *shadow.DurationOfOfferingIsMoreThanYear, - SecuritiesOfferedAreOfEquityType: *shadow.SecuritiesOfferedAreOfEquityType, + SecuritiesOfferedAreOfEquityType: securitiesOfferedAreOfEquityType, IsBusinessCombinationTransaction: *shadow.IsBusinessCombinationTransaction, MinimumInvestmentAccepted: *shadow.MinimumInvestmentAccepted, TotalOfferingAmount: *shadow.TotalOfferingAmount, diff --git a/sdk/go/fundraising_parity_test.go b/sdk/go/fundraising_parity_test.go index 21f62281..6dbd3fbb 100644 --- a/sdk/go/fundraising_parity_test.go +++ b/sdk/go/fundraising_parity_test.go @@ -4,6 +4,7 @@ import ( "encoding/json/jsontext" "encoding/json/v2" "errors" + "reflect" "slices" "strings" "testing" @@ -83,7 +84,7 @@ func TestDocumentedRegulationDOfferingsDecodeExactValues(t *testing.T) { row.AcceptedDate.String() != "2026-07-30 13:05:23" || row.FormType != "D" || row.IncorporatedWithinFiveYears == nil || !*row.IncorporatedWithinFiveYears || row.YearOfIncorporation != "2026" || row.DateOfFirstSale != nil || row.TotalNumberAlreadyInvested != 0 || - row.IsAmendment || !row.SecuritiesOfferedAreOfEquityType { + row.IsAmendment || !reflect.DeepEqual(row.SecuritiesOfferedAreOfEquityType, new(true)) { t.Fatalf("fundraising_latest = %+v", row) } if members := memberSet(t, row); len(members) != 43 { @@ -219,3 +220,67 @@ func TestFundraisingRequiredMembersAndCodecsAreEnforcedLikeSerde(t *testing.T) { t.Fatal("a JSON bool decoded into the Y/N string flag") } } + +// Mirrors nullable_members_are_required_present_and_all_other_keys_are_non_null in +// crates/libfmp/tests/fundraising_crowdfunding_responses.rs: members FMP sends as null +// decode to nil, while a missing member still fails. +func TestCrowdfundingNullableMembersDecodeNullAsNil(t *testing.T) { + t.Parallel() + const fixture = "crowdfunding_offerings_latest.json" + cases := []struct { + member string + isNil func(CrowdfundingOffering) bool + }{ + {"compensationAmount", func(row CrowdfundingOffering) bool { return row.CompensationAmount == nil }}, + {"financialInterest", func(row CrowdfundingOffering) bool { return row.FinancialInterest == nil }}, + {"intermediaryCommissionFileNumber", func(row CrowdfundingOffering) bool { return row.IntermediaryCommissionFileNumber == nil }}, + {"intermediaryCompanyName", func(row CrowdfundingOffering) bool { return row.IntermediaryCompanyName == nil }}, + {"issuerWebsite", func(row CrowdfundingOffering) bool { return row.IssuerWebsite == nil }}, + {"offeringDeadlineDate", func(row CrowdfundingOffering) bool { return row.OfferingDeadlineDate == nil }}, + {"overSubscriptionAllocationType", func(row CrowdfundingOffering) bool { return row.OverSubscriptionAllocationType == nil }}, + {"securityOfferedType", func(row CrowdfundingOffering) bool { return row.SecurityOfferedType == nil }}, + } + for _, tc := range cases { + t.Run(tc.member, func(t *testing.T) { + t.Parallel() + var missing []CrowdfundingOffering + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, nil), &missing); err == nil { + t.Fatalf("missing %s decoded", tc.member) + } + var rows []CrowdfundingOffering + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, jsontext.Value(`null`)), &rows); err != nil || + len(rows) != 1 || !tc.isNil(rows[0]) { + t.Fatalf("null %s = %+v, %v, want nil", tc.member, rows, err) + } + }) + } +} + +// Mirrors nullable_members_are_required_present_and_every_other_key_is_non_null in +// crates/libfmp/tests/fundraising_regulation_d_responses.rs: members FMP sends as null +// decode to nil, while a missing member still fails. +func TestRegulationDNullableMembersDecodeNullAsNil(t *testing.T) { + t.Parallel() + const fixture = "fundraising_latest.json" + cases := []struct { + member string + isNil func(RegulationDOffering) bool + }{ + {"revenueRange", func(row RegulationDOffering) bool { return row.RevenueRange == nil }}, + {"securitiesOfferedAreOfEquityType", func(row RegulationDOffering) bool { return row.SecuritiesOfferedAreOfEquityType == nil }}, + } + for _, tc := range cases { + t.Run(tc.member, func(t *testing.T) { + t.Parallel() + var missing []RegulationDOffering + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, nil), &missing); err == nil { + t.Fatalf("missing %s decoded", tc.member) + } + var rows []RegulationDOffering + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, jsontext.Value(`null`)), &rows); err != nil || + len(rows) != 1 || !tc.isNil(rows[0]) { + t.Fatalf("null %s = %+v, %v, want nil", tc.member, rows, err) + } + }) + } +} diff --git a/sdk/go/sec_filings_models.go b/sdk/go/sec_filings_models.go index 833a19a4..f2caa493 100644 --- a/sdk/go/sec_filings_models.go +++ b/sdk/go/sec_filings_models.go @@ -76,26 +76,26 @@ func (m *SECFiling) UnmarshalJSONFrom(dec *jsontext.Decoder) error { // SECCompanySearchResult is one company identity returned by the three SEC // company-search routes. type SECCompanySearchResult struct { - Symbol string `json:"symbol"` - Name string `json:"name"` - CIK string `json:"cik"` - SicCode string `json:"sicCode"` - IndustryTitle string `json:"industryTitle"` - BusinessAddress string `json:"businessAddress"` - PhoneNumber string `json:"phoneNumber"` + Symbol string `json:"symbol"` + Name string `json:"name"` + CIK string `json:"cik"` + SicCode string `json:"sicCode"` + IndustryTitle string `json:"industryTitle"` + BusinessAddress string `json:"businessAddress"` + PhoneNumber *string `json:"phoneNumber"` } // secCompanySearchResultShadow mirrors SECCompanySearchResult with a pointer // or raw value for every required member so a missing or null member is // observable after decoding. type secCompanySearchResultShadow struct { - Symbol *string `json:"symbol"` - Name *string `json:"name"` - CIK *string `json:"cik"` - SicCode *string `json:"sicCode"` - IndustryTitle *string `json:"industryTitle"` - BusinessAddress *string `json:"businessAddress"` - PhoneNumber *string `json:"phoneNumber"` + Symbol *string `json:"symbol"` + Name *string `json:"name"` + CIK *string `json:"cik"` + SicCode *string `json:"sicCode"` + IndustryTitle *string `json:"industryTitle"` + BusinessAddress *string `json:"businessAddress"` + PhoneNumber jsontext.Value `json:"phoneNumber"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -119,9 +119,17 @@ func (m *SECCompanySearchResult) UnmarshalJSONFrom(dec *jsontext.Decoder) error return missingMemberError("SECCompanySearchResult", "industryTitle") case shadow.BusinessAddress == nil: return missingMemberError("SECCompanySearchResult", "businessAddress") - case shadow.PhoneNumber == nil: + case len(shadow.PhoneNumber) == 0: return missingMemberError("SECCompanySearchResult", "phoneNumber") } + var phoneNumber *string + if shadow.PhoneNumber.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.PhoneNumber, &value); err != nil { + return err + } + phoneNumber = &value + } *m = SECCompanySearchResult{ Symbol: *shadow.Symbol, Name: *shadow.Name, @@ -129,7 +137,7 @@ func (m *SECCompanySearchResult) UnmarshalJSONFrom(dec *jsontext.Decoder) error SicCode: *shadow.SicCode, IndustryTitle: *shadow.IndustryTitle, BusinessAddress: *shadow.BusinessAddress, - PhoneNumber: *shadow.PhoneNumber, + PhoneNumber: phoneNumber, } return nil } @@ -156,9 +164,9 @@ type SECCompanyProfile struct { Exchange string `json:"exchange"` StateLocation string `json:"stateLocation"` StateOfIncorporation string `json:"stateOfIncorporation"` - FiscalYearEnd string `json:"fiscalYearEnd"` + FiscalYearEnd *string `json:"fiscalYearEnd"` IPODate Date `json:"ipoDate"` - Employees string `json:"employees"` + Employees *string `json:"employees"` SECFilingsURL string `json:"secFilingsUrl"` TaxIdentificationNumber string `json:"taxIdentificationNumber"` FiftyTwoWeekRange string `json:"fiftyTwoWeekRange"` @@ -197,9 +205,9 @@ type secCompanyProfileShadow struct { Exchange *string `json:"exchange"` StateLocation *string `json:"stateLocation"` StateOfIncorporation *string `json:"stateOfIncorporation"` - FiscalYearEnd *string `json:"fiscalYearEnd"` + FiscalYearEnd jsontext.Value `json:"fiscalYearEnd"` IPODate *Date `json:"ipoDate"` - Employees *string `json:"employees"` + Employees jsontext.Value `json:"employees"` SECFilingsURL *string `json:"secFilingsUrl"` TaxIdentificationNumber *string `json:"taxIdentificationNumber"` FiftyTwoWeekRange *string `json:"fiftyTwoWeekRange"` @@ -263,11 +271,11 @@ func (m *SECCompanyProfile) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("SECCompanyProfile", "stateLocation") case shadow.StateOfIncorporation == nil: return missingMemberError("SECCompanyProfile", "stateOfIncorporation") - case shadow.FiscalYearEnd == nil: + case len(shadow.FiscalYearEnd) == 0: return missingMemberError("SECCompanyProfile", "fiscalYearEnd") case shadow.IPODate == nil: return missingMemberError("SECCompanyProfile", "ipoDate") - case shadow.Employees == nil: + case len(shadow.Employees) == 0: return missingMemberError("SECCompanyProfile", "employees") case shadow.SECFilingsURL == nil: return missingMemberError("SECCompanyProfile", "secFilingsUrl") @@ -294,6 +302,22 @@ func (m *SECCompanyProfile) UnmarshalJSONFrom(dec *jsontext.Decoder) error { case shadow.IsFund == nil: return missingMemberError("SECCompanyProfile", "isFund") } + var fiscalYearEnd *string + if shadow.FiscalYearEnd.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.FiscalYearEnd, &value); err != nil { + return err + } + fiscalYearEnd = &value + } + var employees *string + if shadow.Employees.Kind() != 'n' { + var value string + if err := json.Unmarshal(shadow.Employees, &value); err != nil { + return err + } + employees = &value + } var securityType *jsontext.Value if shadow.SecurityType.Kind() != 'n' { var value jsontext.Value @@ -323,9 +347,9 @@ func (m *SECCompanyProfile) UnmarshalJSONFrom(dec *jsontext.Decoder) error { Exchange: *shadow.Exchange, StateLocation: *shadow.StateLocation, StateOfIncorporation: *shadow.StateOfIncorporation, - FiscalYearEnd: *shadow.FiscalYearEnd, + FiscalYearEnd: fiscalYearEnd, IPODate: *shadow.IPODate, - Employees: *shadow.Employees, + Employees: employees, SECFilingsURL: *shadow.SECFilingsURL, TaxIdentificationNumber: *shadow.TaxIdentificationNumber, FiftyTwoWeekRange: *shadow.FiftyTwoWeekRange, diff --git a/sdk/go/sec_filings_parity_test.go b/sdk/go/sec_filings_parity_test.go index aac55f75..fa7cdff6 100644 --- a/sdk/go/sec_filings_parity_test.go +++ b/sdk/go/sec_filings_parity_test.go @@ -4,6 +4,7 @@ import ( "encoding/json/jsontext" "encoding/json/v2" "errors" + "reflect" "strings" "testing" ) @@ -89,15 +90,15 @@ func TestDocumentedSecCompanySearchRowsDecodeExactValues(t *testing.T) { SicCode: "", IndustryTitle: "", BusinessAddress: "c/o Berkshire Property Advisors LLC, Boston MA 02108", - PhoneNumber: "(617) 646-2300", + PhoneNumber: new("(617) 646-2300"), } - if len(names) != 1 || names[0] != wantName { + if len(names) != 1 || !reflect.DeepEqual(names[0], wantName) { t.Fatalf("sec_companies_by_name = %+v, want %+v", names, wantName) } bySymbol := assertFixtureParity[SECCompanySearchResult](t, "sec_companies_by_symbol.json") byCIK := assertFixtureParity[SECCompanySearchResult](t, "sec_companies_by_cik.json") - if len(bySymbol) != 1 || len(byCIK) != 1 || bySymbol[0] != byCIK[0] || + if len(bySymbol) != 1 || len(byCIK) != 1 || !reflect.DeepEqual(bySymbol[0], byCIK[0]) || bySymbol[0].Symbol != "AAPL" || bySymbol[0].Name != "APPLE INC." || byCIK[0].CIK != "0000320193" { t.Fatalf("by_symbol = %+v, by_cik = %+v, want the same AAPL row", bySymbol, byCIK) } @@ -122,7 +123,7 @@ func TestDocumentedSecCompanyProfileDecodesExactValues(t *testing.T) { } row := rows[0] if row.Symbol != "AAPL" || row.CIK != "0000320193" || row.ISIN != "US0378331005" || row.Country != "US" || - row.Exchange != "NASDAQ" || row.IPODate != mustParseDate(t, "1980-12-12") || row.Employees != "166000" || + row.Exchange != "NASDAQ" || row.IPODate != mustParseDate(t, "1980-12-12") || !reflect.DeepEqual(row.Employees, new("166000")) || row.PriceCurrency != "USD" || row.MarketSector != "Technology" || row.SecurityType != nil || !row.IsActive || row.IsETF || row.IsAdr || row.IsFund { t.Fatalf("sec_company_profile[0] = %+v", row) @@ -246,3 +247,60 @@ func TestSecFilingsRequiredMembersAreEnforcedLikeSerde(t *testing.T) { t.Fatalf("SECFiling error = %v, want it to name the null member link", err) } } + +// Mirrors nullable_profile_and_search_members_are_required_and_null_decodes_to_none in +// crates/libfmp/tests/sec_filings_responses.rs: members FMP sends as null +// decode to nil, while a missing member still fails. +func TestSecCompanyProfileNullableMembersDecodeNullAsNil(t *testing.T) { + t.Parallel() + const fixture = "sec_company_profile.json" + cases := []struct { + member string + isNil func(SECCompanyProfile) bool + }{ + {"employees", func(row SECCompanyProfile) bool { return row.Employees == nil }}, + {"fiscalYearEnd", func(row SECCompanyProfile) bool { return row.FiscalYearEnd == nil }}, + } + for _, tc := range cases { + t.Run(tc.member, func(t *testing.T) { + t.Parallel() + var missing []SECCompanyProfile + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, nil), &missing); err == nil { + t.Fatalf("missing %s decoded", tc.member) + } + var rows []SECCompanyProfile + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, jsontext.Value(`null`)), &rows); err != nil || + len(rows) != 1 || !tc.isNil(rows[0]) { + t.Fatalf("null %s = %+v, %v, want nil", tc.member, rows, err) + } + }) + } +} + +// Mirrors nullable_profile_and_search_members_are_required_and_null_decodes_to_none in +// crates/libfmp/tests/sec_filings_responses.rs: members FMP sends as null +// decode to nil, while a missing member still fails. +func TestSecCompanySearchPhoneNumberDecodesNullAsNil(t *testing.T) { + t.Parallel() + const fixture = "sec_companies_by_name.json" + cases := []struct { + member string + isNil func(SECCompanySearchResult) bool + }{ + {"phoneNumber", func(row SECCompanySearchResult) bool { return row.PhoneNumber == nil }}, + } + for _, tc := range cases { + t.Run(tc.member, func(t *testing.T) { + t.Parallel() + var missing []SECCompanySearchResult + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, nil), &missing); err == nil { + t.Fatalf("missing %s decoded", tc.member) + } + var rows []SECCompanySearchResult + if err := json.Unmarshal(mutateFixtureMember(t, fixture, tc.member, jsontext.Value(`null`)), &rows); err != nil || + len(rows) != 1 || !tc.isNil(rows[0]) { + t.Fatalf("null %s = %+v, %v, want nil", tc.member, rows, err) + } + }) + } +} diff --git a/sdk/go/sec_filings_test.go b/sdk/go/sec_filings_test.go index 25c75d3b..cf53e80f 100644 --- a/sdk/go/sec_filings_test.go +++ b/sdk/go/sec_filings_test.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "net/http" + "reflect" "strings" "testing" ) @@ -93,7 +94,7 @@ func TestSecFilingsMethodsUseExactPathsAndWireParameterOrder(t *testing.T) { t.Fatalf("SearchCompaniesBySymbol = %+v, %v", companyBySymbol, err) } companyByCIK, err := client.SECFilings.SearchCompaniesByCIK(ctx, NewSECCompaniesByCIKQuery("0000320193")) - if err != nil || len(companyByCIK) != 1 || companyByCIK[0] != companyBySymbol[0] { + if err != nil || len(companyByCIK) != 1 || !reflect.DeepEqual(companyByCIK[0], companyBySymbol[0]) { t.Fatalf("SearchCompaniesByCIK = %+v, %v", companyByCIK, err) } profile, err := client.SECFilings.CompanyProfile(ctx, NewSECCompanyProfileQuery("AAPL").WithCIKA("0000320193"))