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/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/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/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/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/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/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/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/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/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/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/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_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_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/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_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_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/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 1dab68c0..a438c6a7 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( @@ -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") @@ -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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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) } 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) + } + }) + } +} 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"))