diff --git a/Cargo.lock b/Cargo.lock index 65ea3603..3544b394 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -716,6 +716,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_path_to_error", "tokio", "url", ] @@ -1433,6 +1434,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index 8d475e9c..eeb9439a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ pyo3-stub-gen = { version = "0.23", default-features = false, features = ["infer reqwest = { version = "0.13.4", default-features = false } serde = { version = "1.0.229", features = ["derive"] } serde_json = { version = "1.0.149", features = ["arbitrary_precision"] } +serde_path_to_error = "0.1.20" syn = { version = "2.0", features = ["full", "extra-traits"] } thiserror = "2.0" tokio = { version = "1.52.3", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] } diff --git a/crates/fmp-py/README.md b/crates/fmp-py/README.md index da73e226..74fac430 100644 --- a/crates/fmp-py/README.md +++ b/crates/fmp-py/README.md @@ -142,12 +142,12 @@ package root): | Exception | Raised when | |-----------|-------------| -| `FmpError` | base class; carries `category`, `endpoint`, `status`, `body`, `body_truncated` | +| `FmpError` | base class; carries `category`, `endpoint`, `status`, `body`, `body_truncated`, `decode_path`, `decode_kind` | | `FmpConfigError` | the client cannot be built (missing key, bad URL, insecure auth) | | `FmpValidationError` | an argument is rejected before the request | | `FmpTransportError` | the request never produced a response | | `FmpStatusError` | the provider answered with a non-success status | -| `FmpDecodeError` | the body could not be decoded into the documented shape | +| `FmpDecodeError` | the body could not be decoded into the documented shape; `decode_path` names the member (for example `[37].beta`) and `decode_kind` the reason, never the value | ```python from fmp import FmpClient diff --git a/crates/fmp-py/python/fmp/_native/errors/__init__.pyi b/crates/fmp-py/python/fmp/_native/errors/__init__.pyi index 0d6e47b5..d7e50af1 100644 --- a/crates/fmp-py/python/fmp/_native/errors/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/errors/__init__.pyi @@ -64,6 +64,18 @@ class FmpError(builtins.Exception): Whether `body` was cut short to stay within the excerpt bound. """ + @property + def decode_path(self) -> typing.Optional[builtins.str]: + r""" + Where a JSON response failed to decode, for example `"[37].beta"`; never the member value. + """ + + @property + def decode_kind(self) -> typing.Optional[builtins.str]: + r""" + Why a JSON response failed to decode: `"syntax"`, `"null"`, `"missing_member"`, `"wrong_type"`, or `"invalid_value"`. + """ + class FmpStatusError(FmpError): r""" diff --git a/crates/fmp-py/src/errors.rs b/crates/fmp-py/src/errors.rs index 098a8f35..c16b542d 100644 --- a/crates/fmp-py/src/errors.rs +++ b/crates/fmp-py/src/errors.rs @@ -64,6 +64,20 @@ static ERROR_ATTRIBUTES: &[MemberInfo] = &[ default: None, deprecated: None, }, + MemberInfo { + name: "decode_path", + r#type: as PyStubType>::type_output, + doc: "Where a JSON response failed to decode, for example `\"[37].beta\"`; never the member value.", + default: None, + deprecated: None, + }, + MemberInfo { + name: "decode_kind", + r#type: as PyStubType>::type_output, + doc: "Why a JSON response failed to decode: `\"syntax\"`, `\"null\"`, `\"missing_member\"`, `\"wrong_type\"`, or `\"invalid_value\"`.", + default: None, + deprecated: None, + }, ]; /// Declares one exception type: the pyo3 type object under `fmp.errors` plus @@ -188,6 +202,8 @@ fn set_validation_attributes(exception: &Bound<'_, PyBaseException>) -> PyResult exception.setattr("status", py.None())?; exception.setattr("body", py.None())?; exception.setattr("body_truncated", py.None())?; + exception.setattr("decode_path", py.None())?; + exception.setattr("decode_kind", py.None())?; Ok(()) } @@ -203,6 +219,13 @@ fn set_error_attributes( "body_truncated", error.body().map(libfmp::error::SafeBody::is_truncated), )?; + exception.setattr("decode_path", error.decode_path())?; + exception.setattr( + "decode_kind", + error + .decode_kind() + .map(libfmp::error::DecodeErrorKind::as_str), + )?; Ok(()) } @@ -219,6 +242,8 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { base.setattr("status", py.None())?; base.setattr("body", py.None())?; base.setattr("body_truncated", py.None())?; + base.setattr("decode_path", py.None())?; + base.setattr("decode_kind", py.None())?; py.get_type::() .setattr("category", "validation")?; diff --git a/crates/fmp-py/tests/test_analyst.py b/crates/fmp-py/tests/test_analyst.py index 5464f53f..243ca3b8 100644 --- a/crates/fmp-py/tests/test_analyst.py +++ b/crates/fmp-py/tests/test_analyst.py @@ -232,11 +232,13 @@ def test_stock_grades_summary_accepts_an_integral_float_count(client: Any, fixtu def test_stock_grades_summary_rejects_a_fractional_count( client: Any, fixture_server: FixtureServer, errors: SimpleNamespace ) -> None: - """A fractional count such as ``2.9`` maps to ``FmpDecodeError`` with the endpoint id.""" + """A fractional count such as ``2.9`` maps to ``FmpDecodeError`` naming the endpoint and member.""" fixture_server.route("/grades-consensus", [{**load_fixture("stock_grades_summary.json")[0], "buy": 2.9}]) with pytest.raises(errors.FmpDecodeError) as raised: client.analyst.stock_grades_summary("AAPL") assert raised.value.endpoint == "grades-consensus" + assert raised.value.decode_path == "[0].buy" + assert raised.value.decode_kind == "invalid_value" def test_empty_array_decodes_to_an_empty_list(client: Any, fixture_server: FixtureServer) -> None: diff --git a/crates/fmp-py/tests/test_import.py b/crates/fmp-py/tests/test_import.py index a7d11560..b3232b1d 100644 --- a/crates/fmp-py/tests/test_import.py +++ b/crates/fmp-py/tests/test_import.py @@ -245,3 +245,4 @@ def test_exception_attributes_default_to_none(errors: SimpleNamespace, name: str assert exception.args == ("message",) assert exception.category == category assert (exception.endpoint, exception.status, exception.body, exception.body_truncated) == (None, None, None, None) + assert (exception.decode_path, exception.decode_kind) == (None, None) diff --git a/crates/fmp-py/tests/test_screener.py b/crates/fmp-py/tests/test_screener.py index 04479d15..528a24e7 100644 --- a/crates/fmp-py/tests/test_screener.py +++ b/crates/fmp-py/tests/test_screener.py @@ -213,9 +213,46 @@ def test_status_error_carries_the_screener_endpoint_id( def test_decode_error_for_a_non_json_body(client: Any, fixture_server: FixtureServer, errors: SimpleNamespace) -> None: """A body that is not JSON maps to ``FmpDecodeError`` rather than a raw exception.""" fixture_server.route("/company-screener", b"not-json") - with pytest.raises(errors.FmpDecodeError): + with pytest.raises(errors.FmpDecodeError) as caught: client.screener.companies() + assert (caught.value.decode_kind, caught.value.decode_path) == ("syntax", None) + + +def test_decode_error_names_the_null_member_row_and_member( + client: Any, fixture_server: FixtureServer, errors: SimpleNamespace +) -> None: + """Issue #366: a ``null`` required member names its row index and member.""" + fixture_server.route("/company-screener", load_fixture("company_screener_null_beta_synthetic.json")) + with pytest.raises(errors.FmpDecodeError) as caught: + client.screener.companies() + + assert caught.value.decode_path == "[37].beta" + assert caught.value.decode_kind == "null" + assert str(caught.value).startswith( + "successful response could not be decoded: null value at [37].beta (endpoint: company-screener): " + ) + + +def test_decode_error_never_carries_a_string_member_value( + client: Any, fixture_server: FixtureServer, errors: SimpleNamespace +) -> None: + """Issue #366: the offending string value reaches neither the message nor an attribute.""" + sentinel = "SENTINEL-beta-text" + rows = load_fixture("company_screener_null_beta_synthetic.json") + rows[37]["beta"] = sentinel + fixture_server.route("/company-screener", rows) + with pytest.raises(errors.FmpDecodeError) as caught: + client.screener.companies() + + error = caught.value + assert error.decode_path == "[37].beta" + assert error.decode_kind == "wrong_type" + assert error.body_truncated is True + assert sentinel not in str(error) + assert sentinel not in repr(error) + assert all(sentinel not in str(value) for value in vars(error).values()) + def test_companies_decodes_integral_float_and_fractional_market_caps(client: Any, fixture_server: FixtureServer) -> None: """Issue #339: an integral-float or fractional ``marketCap`` decodes as a ``float``.""" diff --git a/crates/libfmp/Cargo.toml b/crates/libfmp/Cargo.toml index d0d11839..7fd53c52 100644 --- a/crates/libfmp/Cargo.toml +++ b/crates/libfmp/Cargo.toml @@ -35,6 +35,7 @@ http.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +serde_path_to_error.workspace = true tokio.workspace = true url.workspace = true diff --git a/crates/libfmp/src/client.rs b/crates/libfmp/src/client.rs index 8bd534db..44be24fc 100644 --- a/crates/libfmp/src/client.rs +++ b/crates/libfmp/src/client.rs @@ -489,13 +489,17 @@ impl Client { response.body_bytes(), ResponseMetadata::new(content_type, content_disposition), ) - .map_err(|_| { + .map_err(|failure| { Error::decode( Some(endpoint.id()), Some(response.status()), Some(safe_body(response.body(), redactor)), "successful response could not be decoded", ) + .with_decode_location( + failure.path.map(|path| redactor.redact(&path)), + failure.kind, + ) }); } diff --git a/crates/libfmp/src/endpoints/mod.rs b/crates/libfmp/src/endpoints/mod.rs index efe0cb26..fa67cca0 100644 --- a/crates/libfmp/src/endpoints/mod.rs +++ b/crates/libfmp/src/endpoints/mod.rs @@ -38,7 +38,7 @@ use std::{fmt, marker::PhantomData}; use bytes::Bytes; use serde::de::DeserializeOwned; -use crate::transport::HttpMethod; +use crate::{error::DecodeErrorKind, transport::HttpMethod}; use metadata::EndpointMetadata; /// The JSON media type requested by FMP's structured-data endpoints. @@ -335,7 +335,17 @@ impl<'a> ResponseMetadata<'a> { } } -type Decoder = for<'a> fn(Bytes, ResponseMetadata<'a>) -> std::result::Result; +/// Where and why a response body failed to decode. +/// +/// Only the member path and a coarse kind survive: the decoder's own message +/// can quote the offending value, so it is classified and then dropped. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DecodeFailure { + pub(crate) path: Option, + pub(crate) kind: DecodeErrorKind, +} + +type Decoder = for<'a> fn(Bytes, ResponseMetadata<'a>) -> std::result::Result; /// Decoding and media-type expectations associated with one endpoint. pub struct ResponseContract { @@ -354,7 +364,7 @@ impl ResponseContract { &self, body: Bytes, metadata: ResponseMetadata<'_>, - ) -> std::result::Result { + ) -> std::result::Result { (self.decoder)(body, metadata) } } @@ -394,11 +404,56 @@ impl fmt::Debug for ResponseContract { } } -fn decode_json(body: Bytes, _metadata: ResponseMetadata<'_>) -> std::result::Result +fn decode_json( + body: Bytes, + _metadata: ResponseMetadata<'_>, +) -> std::result::Result where R: DeserializeOwned, { - serde_json::from_slice(&body).map_err(|_| ()) + let mut deserializer = serde_json::Deserializer::from_slice(&body); + let value = serde_path_to_error::deserialize(&mut deserializer) + .map_err(|error| decode_failure(error.path(), error.inner()))?; + deserializer.end().map_err(|_| DecodeFailure { + path: None, + kind: DecodeErrorKind::Syntax, + })?; + Ok(value) +} + +fn decode_failure(path: &serde_path_to_error::Path, error: &serde_json::Error) -> DecodeFailure { + let message = error.to_string(); + let kind = match error.classify() { + serde_json::error::Category::Data => data_error_kind(&message), + _ => DecodeErrorKind::Syntax, + }; + let mut path = (path.iter().next().is_some()).then(|| path.to_string()); + if kind == DecodeErrorKind::MissingMember + && let Some(member) = missing_member_name(&message) + { + path = Some(match path { + Some(parent) => format!("{parent}.{member}"), + None => member.to_owned(), + }); + } + DecodeFailure { path, kind } +} + +fn data_error_kind(message: &str) -> DecodeErrorKind { + if message.starts_with("invalid type: null") { + DecodeErrorKind::Null + } else if message.starts_with("invalid type: ") { + DecodeErrorKind::WrongType + } else if message.starts_with("missing field ") { + DecodeErrorKind::MissingMember + } else { + DecodeErrorKind::InvalidValue + } +} + +fn missing_member_name(message: &str) -> Option<&str> { + let name = message.strip_prefix("missing field `")?; + name.split_once('`').map(|(name, _)| name) } /// An owned binary response with validated media metadata. @@ -466,7 +521,7 @@ pub type BinaryBody = BinaryResponse; fn decode_binary( body: Bytes, metadata: ResponseMetadata<'_>, -) -> std::result::Result { +) -> std::result::Result { Ok(BinaryResponse { bytes: body, content_type: metadata.content_type.into(), diff --git a/crates/libfmp/src/error.rs b/crates/libfmp/src/error.rs index d0353d0e..5a378b0f 100644 --- a/crates/libfmp/src/error.rs +++ b/crates/libfmp/src/error.rs @@ -81,6 +81,54 @@ pub enum ConfigurationErrorKind { HttpClient, } +/// Coarse reasons a successful JSON response failed to decode. +/// +/// The kind never carries the offending member value: it is derived from the +/// decoder failure and the decoder's own text is discarded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum DecodeErrorKind { + /// The body is not well-formed JSON. + Syntax, + /// A member that requires a value was JSON `null`. + Null, + /// A required object member was absent. + MissingMember, + /// A member had a JSON type the documented shape does not accept. + WrongType, + /// A member had the right JSON type but an unacceptable value. + InvalidValue, +} + +impl DecodeErrorKind { + /// Returns the stable lowercase value exposed to language bindings. + pub const fn as_str(self) -> &'static str { + match self { + Self::Syntax => "syntax", + Self::Null => "null", + Self::MissingMember => "missing_member", + Self::WrongType => "wrong_type", + Self::InvalidValue => "invalid_value", + } + } + + const fn description(self) -> &'static str { + match self { + Self::Syntax => "malformed JSON", + Self::Null => "null value", + Self::MissingMember => "missing member", + Self::WrongType => "wrong type", + Self::InvalidValue => "invalid value", + } + } +} + +impl fmt::Display for DecodeErrorKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + impl ErrorCategory { /// Returns the stable lowercase value exposed to language bindings. pub const fn as_str(self) -> &'static str { @@ -461,6 +509,8 @@ pub struct Error { status: Option, body: Option, configuration_kind: Option, + decode_path: Option>, + decode_kind: Option, } impl Error { @@ -528,9 +578,24 @@ impl Error { status: None, body: None, configuration_kind: None, + decode_path: None, + decode_kind: None, } } + /// Attaches where and why a JSON response failed to decode. + /// + /// `path` names members and array indexes only, never a member value. + pub(crate) fn with_decode_location( + mut self, + path: Option, + kind: DecodeErrorKind, + ) -> Self { + self.decode_path = path.map(String::into_boxed_str); + self.decode_kind = Some(kind); + self + } + fn with_endpoint(mut self, endpoint: Option<&'static str>) -> Self { self.endpoint = endpoint; self @@ -565,11 +630,32 @@ impl Error { pub fn configuration_kind(&self) -> Option { self.configuration_kind } + + /// Returns the location of the member that failed to decode, when known. + /// + /// The path uses array indexes and member names, for example + /// `[37].beta`, and never includes the member value. It is `None` for + /// failures at the document root and for response kinds that are not + /// decoded from JSON, such as binary bodies. + pub fn decode_path(&self) -> Option<&str> { + self.decode_path.as_deref() + } + + /// Returns the coarse reason a JSON response failed to decode, when known. + pub fn decode_kind(&self) -> Option { + self.decode_kind + } } impl fmt::Display for Error { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&self.message)?; + if let Some(kind) = self.decode_kind { + write!(formatter, ": {}", kind.description())?; + } + if let Some(path) = &self.decode_path { + write!(formatter, " at {path}")?; + } if let Some(endpoint) = &self.endpoint { write!(formatter, " (endpoint: {endpoint})")?; } diff --git a/crates/libfmp/tests/analyst_stock_grades_endpoints.rs b/crates/libfmp/tests/analyst_stock_grades_endpoints.rs index 9226adf1..cfb56427 100644 --- a/crates/libfmp/tests/analyst_stock_grades_endpoints.rs +++ b/crates/libfmp/tests/analyst_stock_grades_endpoints.rs @@ -13,7 +13,7 @@ use libfmp::{ }, metadata::{AccessRequirement, EndpointBounds, GeographicAvailability}, }, - error::ErrorCategory, + error::{DecodeErrorKind, ErrorCategory}, responses::analyst::{HistoricalStockGrade, StockGrade, StockGradesSummary}, transport::HttpMethod, types::{Limit, Ticker}, @@ -288,6 +288,9 @@ async fn count_members_accept_integral_floats_and_reject_fractions() { let error = client.stock_grades_summary(query()).await.unwrap_err(); assert_decode_error(&error, "grades-consensus"); + assert_eq!(error.decode_path(), Some("[0].buy")); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::InvalidValue)); + assert!(!error.message().contains("2.9")); } fn assert_decode_error(error: &libfmp::Error, endpoint: &'static str) { diff --git a/crates/libfmp/tests/decode_path.rs b/crates/libfmp/tests/decode_path.rs new file mode 100644 index 00000000..5593b43e --- /dev/null +++ b/crates/libfmp/tests/decode_path.rs @@ -0,0 +1,159 @@ +mod support; + +use std::sync::Arc; + +use libfmp::{ + Client, + config::Authentication, + endpoints::screener::CompanyScreenerQuery, + error::{DecodeErrorKind, ErrorCategory}, + http::{HeaderMap, HeaderValue, header::CONTENT_TYPE}, + transport::TransportResponse, +}; + +use support::{FixtureExecutor, json_fixture}; + +const NULL_BETA: &[u8] = include_bytes!("fixtures/company_screener_null_beta_synthetic.json"); +const SCREENER: &str = include_str!("fixtures/company_screener.json"); +const SENTINEL: &str = "SENTINEL-beta-text"; + +fn json_body(body: String) -> TransportResponse { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + TransportResponse::new(200, headers, body) +} + +async fn decode_error(response: TransportResponse) -> libfmp::Error { + let executor = Arc::new(FixtureExecutor::new([response])); + let client = Client::builder() + .base_url("https://proxy.example/router") + .path_prefix("stable") + .authentication(Authentication::None) + .executor(executor.clone()) + .build() + .unwrap(); + let error = client + .company_screener(CompanyScreenerQuery::new()) + .await + .unwrap_err(); + assert_eq!(executor.requests().len(), 1); + assert_eq!(error.category(), ErrorCategory::Decode); + assert_eq!(error.endpoint(), Some("company-screener")); + assert_eq!(error.message(), "successful response could not be decoded"); + error +} + +#[tokio::test] +async fn null_member_names_the_row_index_and_member() { + let error = decode_error(json_fixture(NULL_BETA)).await; + + assert_eq!(error.decode_path(), Some("[37].beta")); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::Null)); + assert!(error.to_string().starts_with( + "successful response could not be decoded: null value at [37].beta \ + (endpoint: company-screener): " + )); +} + +#[tokio::test] +async fn string_valued_member_never_reaches_the_error_text() { + let body = String::from_utf8(NULL_BETA.to_vec()).unwrap().replacen( + "\"beta\": null", + &format!("\"beta\": \"{SENTINEL}\""), + 1, + ); + let error = decode_error(json_body(body)).await; + + assert_eq!(error.decode_path(), Some("[37].beta")); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::WrongType)); + assert!(error.body().unwrap().is_truncated()); + assert!(!error.to_string().contains(SENTINEL)); + assert!(!format!("{error:?}").contains(SENTINEL)); +} + +#[tokio::test] +async fn location_fields_omit_the_value_even_when_the_body_retains_it() { + let body = SCREENER.replacen("1.097", &format!("\"{SENTINEL}\""), 1); + let error = decode_error(json_body(body)).await; + + assert_eq!(error.decode_path(), Some("[0].beta")); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::WrongType)); + assert!(error.body().unwrap().as_str().contains(SENTINEL)); + assert!(!error.message().contains(SENTINEL)); + assert!(!error.decode_path().unwrap().contains(SENTINEL)); +} + +#[tokio::test] +async fn missing_member_path_names_the_absent_member() { + let body = SCREENER.replacen("\"beta\": 1.097,", "", 1); + let error = decode_error(json_body(body)).await; + + assert_eq!(error.decode_path(), Some("[0].beta")); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::MissingMember)); +} + +#[tokio::test] +async fn number_for_a_string_member_is_a_wrong_type_at_that_member() { + let body = SCREENER.replacen("\"AAPL\"", "5", 1); + let error = decode_error(json_body(body)).await; + + assert_eq!(error.decode_path(), Some("[0].symbol")); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::WrongType)); +} + +#[tokio::test] +async fn rejected_string_value_is_an_invalid_value_without_its_text() { + let body = SCREENER.replacen("\"AAPL\"", "\" \"", 1); + let error = decode_error(json_body(body)).await; + + assert_eq!(error.decode_path(), Some("[0].symbol")); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::InvalidValue)); +} + +#[tokio::test] +async fn root_shape_mismatch_has_a_kind_but_no_path() { + let error = decode_error(json_body("{}".to_owned())).await; + + assert_eq!(error.decode_path(), None); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::WrongType)); + assert!( + error + .to_string() + .starts_with("successful response could not be decoded: wrong type (endpoint:") + ); +} + +#[tokio::test] +async fn malformed_and_trailing_json_are_syntax_failures() { + let malformed = decode_error(json_body("[{\"beta\": nope}]".to_owned())).await; + let trailing = decode_error(json_body("[] []".to_owned())).await; + + assert_eq!(malformed.decode_kind(), Some(DecodeErrorKind::Syntax)); + assert_eq!(trailing.decode_kind(), Some(DecodeErrorKind::Syntax)); + assert_eq!(trailing.decode_path(), None); +} + +#[tokio::test] +async fn non_json_and_empty_bodies_are_root_syntax_failures() { + for body in ["not-json", ""] { + let error = decode_error(json_body(body.to_owned())).await; + + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::Syntax)); + assert_eq!(error.decode_path(), None); + } +} + +#[test] +fn decode_kind_exposes_stable_binding_values() { + let kinds = [ + (DecodeErrorKind::Syntax, "syntax"), + (DecodeErrorKind::Null, "null"), + (DecodeErrorKind::MissingMember, "missing_member"), + (DecodeErrorKind::WrongType, "wrong_type"), + (DecodeErrorKind::InvalidValue, "invalid_value"), + ]; + for (kind, value) in kinds { + assert_eq!(kind.as_str(), value); + assert_eq!(kind.to_string(), value); + } +} diff --git a/crates/libfmp/tests/fixtures/README.md b/crates/libfmp/tests/fixtures/README.md index 0923da43..381d6b3d 100644 --- a/crates/libfmp/tests/fixtures/README.md +++ b/crates/libfmp/tests/fixtures/README.md @@ -37,6 +37,13 @@ network connection. `balance`, in the same three test suites. Issue #341 gave `income_statement_fractional_synthetic.json` a fractional `revenue` and an integral-float `ebitda` to prove `StatementAmount` decoding the same way. +- **Decode-path proof.** `company_screener_null_beta_synthetic.json` is the + one null variant kept as a file: 40 documented rows with `"beta": null` in + row 37, so the offending member lands past the `MAX_SAFE_BODY_BYTES` body + excerpt and the Go and Python suites can share it. It is the issue #366 + decode-path proof in `decode_path.rs`, `sdk/go/decode_path_test.go`, and + the fmp-py screener tests, which also swap in a string value to prove it + never reaches the error. - **Observed-shape reproductions.** `quote_short_fractional_volume.json` reproduces the one live `quote-short` row that motivated issue #337 (seen 2026-09-23: a fractional `volume`, `20201922.82733`); the four values are diff --git a/crates/libfmp/tests/fixtures/company_screener_null_beta_synthetic.json b/crates/libfmp/tests/fixtures/company_screener_null_beta_synthetic.json new file mode 100644 index 00000000..b4e38a75 --- /dev/null +++ b/crates/libfmp/tests/fixtures/company_screener_null_beta_synthetic.json @@ -0,0 +1,682 @@ +[ + { + "beta": 1.097, + "companyName": "Synthetic Row 00", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R00", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 01", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R01", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 02", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R02", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 03", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R03", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 04", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R04", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 05", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R05", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 06", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R06", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 07", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R07", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 08", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R08", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 09", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R09", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 10", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R10", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 11", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R11", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 12", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R12", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 13", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R13", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 14", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R14", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 15", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R15", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 16", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R16", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 17", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R17", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 18", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R18", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 19", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R19", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 20", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R20", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 21", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R21", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 22", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R22", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 23", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R23", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 24", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R24", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 25", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R25", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 26", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R26", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 27", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R27", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 28", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R28", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 29", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R29", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 30", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R30", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 31", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R31", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 32", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R32", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 33", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R33", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 34", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R34", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 35", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R35", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 36", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R36", + "volume": 29909012 + }, + { + "beta": null, + "companyName": "Synthetic Row 37", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R37", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 38", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R38", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 39", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R39", + "volume": 29909012 + } +] diff --git a/sdk/go/README.md b/sdk/go/README.md index 0df49387..9b4985c4 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -158,7 +158,10 @@ if errors.As(err, &fmpErr) { `Category` is one of `Validation`, `Configuration`, `Transport`, `Status`, or `Decode`. `Endpoint` is a logical endpoint id, never a URL. `Body` is the -provider body, redacted and capped. The client never retries, wraps every +provider body, redacted and capped. A `Decode` error also carries `Path`, the +JSON pointer of the failing member (`/37/beta`), and `DecodeKind`, the coarse +reason (`DecodeKindNull`, `DecodeKindWrongType`, ...); neither ever includes +the member value. The client never retries, wraps every transport failure before it escapes, and formats `Authentication`, `Client`, and `Error` without any secret value, so an error is safe to log. diff --git a/sdk/go/analyst_test.go b/sdk/go/analyst_test.go index f77fb8a2..50ec44da 100644 --- a/sdk/go/analyst_test.go +++ b/sdk/go/analyst_test.go @@ -161,5 +161,11 @@ func TestAnalystCountMembersAcceptIntegralFloatsAndRejectFractions(t *testing.T) server, _ = newServer(t, jsonHandler(fmt.Sprintf(row, "2.9"))) client = newClient(t, server, WithAuthentication(FMPHeader("route-secret"))) _, err = client.Analyst.StockGradesSummary(ctx, NewStockGradesSummaryQuery("AAPL")) - _ = assertQuoteError(t, err, CategoryDecode, http.StatusOK, "grades-consensus") + typed := assertQuoteError(t, err, CategoryDecode, http.StatusOK, "grades-consensus") + if typed.Path != "/0/buy" || typed.DecodeKind != DecodeKindInvalidValue { + t.Fatalf("Path, DecodeKind = %q, %v, want /0/buy, invalid_value", typed.Path, typed.DecodeKind) + } + if strings.Contains(typed.Message, "2.9") { + t.Fatalf("Message = %q, must not contain the member value", typed.Message) + } } diff --git a/sdk/go/client.go b/sdk/go/client.go index 35513f98..b203ad72 100644 --- a/sdk/go/client.go +++ b/sdk/go/client.go @@ -306,8 +306,11 @@ func (c *Client) getJSON(ctx context.Context, endpointID, relativePath string, q "successful response used an unexpected content type", nil) } if err := json.Unmarshal(resp.body, out); err != nil { - return decodeError(endpointID, resp.status, c.safeBody(resp.body), + decoded := decodeError(endpointID, resp.status, c.safeBody(resp.body), "successful response could not be decoded", c.redactCause(err)) + path, kind := decodeLocation(resp.body, err) + decoded.Path, decoded.DecodeKind = c.redactor.Redact(path), kind + return decoded } return nil } diff --git a/sdk/go/decode.go b/sdk/go/decode.go index 585d016b..4c0de85d 100644 --- a/sdk/go/decode.go +++ b/sdk/go/decode.go @@ -3,6 +3,7 @@ package fmp import ( "encoding/json/jsontext" "encoding/json/v2" + "errors" "math" "strconv" ) @@ -61,7 +62,7 @@ func decodeCount(model, member string, raw jsontext.Value) (uint64, error) { } value, err := strconv.ParseFloat(text, 64) if err != nil || value < 0 || value >= 0x1p64 || value != math.Trunc(value) { - return 0, invalidMemberError(model, member, "non-negative integral number") + return 0, invalidMemberValueError(model, member, "non-negative integral number") } return uint64(value), nil } @@ -84,8 +85,81 @@ func rawMember(members map[string]jsontext.Value, name string) jsontext.Value { func requireObjectRows(endpointID string, rows []jsontext.Value) error { for index, row := range rows { if row.Kind() != '{' { - return decodeError(endpointID, 0, nil, "row "+strconv.Itoa(index)+" is not a JSON object", nil) + rowErr := decodeError(endpointID, 0, nil, "row "+strconv.Itoa(index)+" is not a JSON object", nil) + rowErr.Path, rowErr.DecodeKind = "/"+strconv.Itoa(index), DecodeKindWrongType + return rowErr } } return nil } + +// decodeLocation turns a json.Unmarshal failure into the JSON pointer of the +// offending member and a coarse DecodeKind. It reads only the structured +// fields of the json/v2 error, never its text, so no member value survives. +// A generated model reports a missing or null required member against the +// enclosing object; the member name is appended and body is consulted to +// tell a JSON null from an absent member. +func decodeLocation(body []byte, err error) (string, DecodeKind) { + var syntactic *jsontext.SyntacticError + if errors.As(err, &syntactic) { + return string(syntactic.JSONPointer), DecodeKindSyntax + } + var semantic *json.SemanticError + if !errors.As(err, &semantic) { + return "", DecodeKindInvalidValue + } + pointer := semantic.JSONPointer + var member *Error + if errors.As(semantic.Err, &member) && member.member != "" { + pointer = pointer.AppendToken(member.member) + value, found := valueAt(body, pointer) + switch { + case !found: + return string(pointer), DecodeKindMissingMember + case value.Kind() == 'n': + return string(pointer), DecodeKindNull + case member.memberKind != DecodeKindNone: + return string(pointer), member.memberKind + default: + return string(pointer), DecodeKindWrongType + } + } + switch { + case semantic.JSONKind == 'n': + return string(pointer), DecodeKindNull + case semantic.Err == nil: + return string(pointer), DecodeKindWrongType + default: + return string(pointer), DecodeKindInvalidValue + } +} + +// valueAt walks body along pointer one level at a time and reports the value +// it names, or false when a token does not resolve. +func valueAt(body []byte, pointer jsontext.Pointer) (jsontext.Value, bool) { + value := jsontext.Value(body) + for token := range pointer.Tokens() { + switch value.Kind() { + case '[': + var items []jsontext.Value + index, err := strconv.Atoi(token) + if json.Unmarshal(value, &items) != nil || err != nil || index < 0 || index >= len(items) { + return nil, false + } + value = items[index] + case '{': + var members map[string]jsontext.Value + if json.Unmarshal(value, &members) != nil { + return nil, false + } + member, ok := members[token] + if !ok { + return nil, false + } + value = member + default: + return nil, false + } + } + return value, true +} diff --git a/sdk/go/decode_path_test.go b/sdk/go/decode_path_test.go new file mode 100644 index 00000000..ae46a670 --- /dev/null +++ b/sdk/go/decode_path_test.go @@ -0,0 +1,111 @@ +package fmp + +import ( + "context" + "encoding/json/jsontext" + "errors" + "net/http" + "strings" + "testing" +) + +const decodePathSentinel = "SENTINEL-beta-text" + +// decodeScreenerBody serves body as the company screener response and returns +// the Decode error the call must fail with. +func decodeScreenerBody(t *testing.T, body []byte) *Error { + t.Helper() + server, _ := newServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }) + client := newClient(t, server) + _, err := client.Screener.Companies(context.Background(), NewCompanyScreenerQuery()) + typed := assertQuoteError(t, err, CategoryDecode, http.StatusOK, "company-screener") + if typed.Message != "successful response could not be decoded" { + t.Fatalf("Message = %q", typed.Message) + } + return typed +} + +func TestDecodeErrorNamesTheNullMemberRowAndMember(t *testing.T) { + t.Parallel() + typed := decodeScreenerBody(t, readFixture(t, "company_screener_null_beta_synthetic.json")) + + if typed.Path != "/37/beta" || typed.DecodeKind != DecodeKindNull { + t.Fatalf("Path = %q, DecodeKind = %v, want /37/beta null", typed.Path, typed.DecodeKind) + } + want := "successful response could not be decoded: null value at /37/beta (endpoint: company-screener): " + if !strings.HasPrefix(typed.Error(), want) { + t.Fatalf("Error() = %.120q, want prefix %q", typed.Error(), want) + } +} + +func TestDecodeErrorNeverCarriesAStringMemberValue(t *testing.T) { + t.Parallel() + body := strings.Replace(string(readFixture(t, "company_screener_null_beta_synthetic.json")), + `"beta": null`, `"beta": "`+decodePathSentinel+`"`, 1) + typed := decodeScreenerBody(t, []byte(body)) + + if typed.Path != "/37/beta" || typed.DecodeKind != DecodeKindWrongType { + t.Fatalf("Path = %q, DecodeKind = %v, want /37/beta wrong_type", typed.Path, typed.DecodeKind) + } + if typed.Body == nil || !typed.Body.Truncated { + t.Fatalf("Body = %+v, want a truncated excerpt that ends before row 37", typed.Body) + } + if strings.Contains(typed.Error(), decodePathSentinel) || strings.Contains(typed.Unwrap().Error(), decodePathSentinel) { + t.Fatalf("decode error leaked the member value: %q / %q", typed.Error(), typed.Unwrap()) + } +} + +func TestDecodeErrorLocatesMissingWrongAndMalformedMembers(t *testing.T) { + t.Parallel() + row := string(readFixture(t, "company_screener.json")) + cases := []struct { + name string + body string + path string + kind DecodeKind + }{ + {"missing", strings.Replace(row, `"beta": 1.097,`, "", 1), "/0/beta", DecodeKindMissingMember}, + {"wrong type", strings.Replace(row, `"AAPL"`, "5", 1), "/0/symbol", DecodeKindWrongType}, + {"root shape", "{}", "", DecodeKindWrongType}, + {"malformed", `[{"beta": nope}]`, "/0/beta", DecodeKindSyntax}, + {"not json", "not-json", "", DecodeKindSyntax}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + typed := decodeScreenerBody(t, []byte(tc.body)) + if typed.Path != tc.path || typed.DecodeKind != tc.kind { + t.Fatalf("Path = %q, DecodeKind = %v, want %q %v", typed.Path, typed.DecodeKind, tc.path, tc.kind) + } + }) + } +} + +func TestDecodeKindStringsMatchTheRustBindingValues(t *testing.T) { + t.Parallel() + want := map[DecodeKind]string{ + DecodeKindNone: "", + DecodeKindSyntax: "syntax", + DecodeKindNull: "null", + DecodeKindMissingMember: "missing_member", + DecodeKindWrongType: "wrong_type", + DecodeKindInvalidValue: "invalid_value", + } + for kind, value := range want { + if kind.String() != value { + t.Fatalf("%d.String() = %q, want %q", int(kind), kind.String(), value) + } + } +} + +func TestRequireObjectRowsLocatesTheNonObjectRow(t *testing.T) { + t.Parallel() + err := requireObjectRows("dynamic-id", []jsontext.Value{jsontext.Value(`{}`), jsontext.Value(`[1]`)}) + var typed *Error + if !errors.As(err, &typed) || typed.Path != "/1" || typed.DecodeKind != DecodeKindWrongType { + t.Fatalf("error = %+v, want Path /1 wrong_type", typed) + } +} diff --git a/sdk/go/errors.go b/sdk/go/errors.go index d511a583..1e5257ca 100644 --- a/sdk/go/errors.go +++ b/sdk/go/errors.go @@ -86,11 +86,72 @@ const ( ConfigurationKindHTTPClient ) +// DecodeKind is the coarse reason a successful JSON response failed to +// decode. It mirrors DecodeErrorKind in the Rust crate and never carries the +// offending member value. The values are stable: new kinds are only appended. +type DecodeKind int + +const ( + // DecodeKindNone marks an error that carries no decode location. + DecodeKindNone DecodeKind = iota + // DecodeKindSyntax: the body is not well-formed JSON. + DecodeKindSyntax + // DecodeKindNull: a member that requires a value was JSON null. + DecodeKindNull + // DecodeKindMissingMember: a required object member was absent. + DecodeKindMissingMember + // DecodeKindWrongType: a member had a JSON type the model does not accept. + DecodeKindWrongType + // DecodeKindInvalidValue: a member had the right JSON type but an unacceptable value. + DecodeKindInvalidValue +) + +// String returns the stable lowercase value shared with the Rust and Python +// SDKs, or "" for DecodeKindNone. +func (k DecodeKind) String() string { + switch k { + case DecodeKindNone: + return "" + case DecodeKindSyntax: + return "syntax" + case DecodeKindNull: + return "null" + case DecodeKindMissingMember: + return "missing_member" + case DecodeKindWrongType: + return "wrong_type" + case DecodeKindInvalidValue: + return "invalid_value" + default: + return fmt.Sprintf("DecodeKind(%d)", int(k)) + } +} + +func (k DecodeKind) description() string { + switch k { + case DecodeKindSyntax: + return "malformed JSON" + case DecodeKindNull: + return "null value" + case DecodeKindMissingMember: + return "missing member" + case DecodeKindWrongType: + return "wrong type" + default: + return "invalid value" + } +} + // Error is the stable error value returned by every client operation. // // Endpoint is a logical endpoint id such as "quote-short", never a URL. // Status is zero when no provider status applies. Body is nil unless a // provider body was retained; it is always redacted and capped. +// +// Path and DecodeKind locate a JSON decode failure. Path is the RFC 6901 JSON +// pointer of the offending member, such as "/37/beta", and names array +// indexes and member names only, never a member value. Path is "" for a +// failure at the document root and on every non-decode error. type Error struct { Category ErrorCategory Message string @@ -98,13 +159,26 @@ type Error struct { Status int Body *SafeBody ConfigurationKind ConfigurationKind + Path string + DecodeKind DecodeKind + member string + memberKind DecodeKind cause error } -// Error formats the message, the endpoint id, and the safe body, in that order. +// Error formats the message, the decode kind and path, the endpoint id, and +// the safe body, in that order. func (e *Error) Error() string { var b strings.Builder b.WriteString(e.Message) + if e.DecodeKind != DecodeKindNone { + b.WriteString(": ") + b.WriteString(e.DecodeKind.description()) + } + if e.Path != "" { + b.WriteString(" at ") + b.WriteString(e.Path) + } if e.Endpoint != "" { b.WriteString(" (endpoint: ") b.WriteString(e.Endpoint) @@ -141,6 +215,7 @@ func missingMemberError(model, member string) *Error { return &Error{ Category: CategoryDecode, Message: fmt.Sprintf("required member %q of %s is missing or null", member, model), + member: member, } } @@ -150,9 +225,18 @@ func invalidMemberError(model, member, expected string) *Error { return &Error{ Category: CategoryDecode, Message: fmt.Sprintf("member %q of %s must be a JSON %s", member, model, expected), + member: member, } } +// invalidMemberValueError reports a member whose JSON kind is right but +// whose value the Rust decoder would reject, such as a fractional count. +func invalidMemberValueError(model, member, expected string) *Error { + err := invalidMemberError(model, member, expected) + err.memberKind = DecodeKindInvalidValue + return err +} + func transportError(endpoint, message string, cause error) *Error { return &Error{Category: CategoryTransport, Message: message, Endpoint: endpoint, cause: cause} }