diff --git a/Cargo.lock b/Cargo.lock index c4e1e3e..9be78d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -529,6 +529,7 @@ dependencies = [ "reqwest", "serde", "serde_json 1.0.149", + "serde_path_to_error", "sha2", "strip-ansi-escapes", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 5b4e014..e42224a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ ratatui = "0.29.0" reqwest = { version = "0.12.7", default-features = false, features = ["json", "rustls-tls-native-roots"] } serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" +serde_path_to_error = "0.1.20" toml = "0.8" sha2 = "0.10.8" strip-ansi-escapes = "0.2.0" diff --git a/src/http.rs b/src/http.rs index 09bdceb..7c8ab39 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use anyhow::{Context, Result}; -use reqwest::header::HeaderValue; -use reqwest::{Client, ClientBuilder}; +use reqwest::header::{HeaderValue, CONTENT_TYPE}; +use reqwest::{Client, ClientBuilder, StatusCode}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -48,6 +48,101 @@ impl std::fmt::Display for HttpError { impl std::error::Error for HttpError {} +#[derive(Debug)] +pub struct ResponseParseError { + message: String, +} + +impl std::fmt::Display for ResponseParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ResponseParseError {} + +async fn parse_json_response( + response: reqwest::Response, + method: &str, + path: &str, +) -> Result { + let status = response.status(); + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let body = response + .bytes() + .await + .context("failed to read response body")?; + + parse_json_body::(&body, method, path, status, content_type.as_deref()) +} + +fn parse_json_body( + body: &[u8], + method: &str, + path: &str, + status: StatusCode, + content_type: Option<&str>, +) -> Result { + let mut deserializer = serde_json::Deserializer::from_slice(body); + let parsed = match serde_path_to_error::deserialize(&mut deserializer) { + Ok(parsed) => parsed, + Err(err) => { + let json_path = err.path().to_string(); + let inner = err.into_inner(); + return Err(parse_response_error::( + method, + path, + status, + content_type, + &json_path, + &inner, + body.len(), + )); + } + }; + + if let Err(err) = deserializer.end() { + return Err(parse_response_error::( + method, + path, + status, + content_type, + "", + &err, + body.len(), + )); + } + + Ok(parsed) +} + +fn parse_response_error( + method: &str, + path: &str, + status: StatusCode, + content_type: Option<&str>, + json_path: &str, + err: &serde_json::Error, + body_len: usize, +) -> anyhow::Error { + let json_path = if json_path.is_empty() || json_path == "." { + "" + } else { + json_path + }; + let content_type = content_type.unwrap_or(""); + let message = format!( + "failed to parse response from {method} {path}\n target: {}\n JSON path: {json_path}\n reason: {err}\n status: {status}\n content-type: {content_type}\n body bytes: {body_len}", + std::any::type_name::(), + ); + + ResponseParseError { message }.into() +} + #[derive(Debug, Deserialize)] pub struct BtqlResponse { pub data: Vec, @@ -105,7 +200,7 @@ impl ApiClient { return Err(HttpError { status, body }.into()); } - response.json().await.context("failed to parse response") + parse_json_response(response, "GET", path).await } pub async fn post(&self, path: &str, body: &B) -> Result { @@ -125,7 +220,7 @@ impl ApiClient { return Err(HttpError { status, body }.into()); } - response.json().await.context("failed to parse response") + parse_json_response(response, "POST", path).await } pub async fn patch( @@ -149,7 +244,7 @@ impl ApiClient { return Err(HttpError { status, body }.into()); } - response.json().await.context("failed to parse response") + parse_json_response(response, "PATCH", path).await } pub async fn post_with_headers( @@ -195,7 +290,7 @@ impl ApiClient { return Err(HttpError { status, body }.into()); } - response.json().await.context("failed to parse response") + parse_json_response(response, "POST", path).await } pub async fn post_with_headers_raw( @@ -318,6 +413,20 @@ pub async fn put_signed_url_with_headers( #[cfg(test)] mod tests { use super::*; + use serde::Deserialize; + use serde_json::json; + + #[allow(dead_code)] + #[derive(Debug, Deserialize)] + struct TestResponse { + data: Vec, + } + + #[allow(dead_code)] + #[derive(Debug, Deserialize)] + struct TestRow { + id: String, + } #[test] fn btql_response_deserializes_optional_cursor() { @@ -339,4 +448,39 @@ mod tests { assert_eq!(response.cursor, None); } + + #[test] + fn parse_json_body_reports_path_and_reason() { + let err = parse_json_body::( + br#"{"data":[{"id":1}]}"#, + "POST", + "/btql", + StatusCode::OK, + Some("application/json"), + ) + .unwrap_err(); + let message = err.to_string(); + + assert!(message.contains("failed to parse response from POST /btql")); + assert!(message.contains("JSON path: data[0].id")); + assert!(message.contains("invalid type: integer `1`, expected a string")); + assert!(message.contains("status: 200 OK")); + assert!(message.contains("content-type: application/json")); + } + + #[test] + fn parse_json_body_rejects_trailing_characters() { + let err = parse_json_body::>( + br#"{"data":[]} trailing"#, + "GET", + "/btql", + StatusCode::OK, + Some("application/json"), + ) + .unwrap_err(); + let message = err.to_string(); + + assert!(message.contains("JSON path: ")); + assert!(message.contains("trailing characters")); + } } diff --git a/src/main.rs b/src/main.rs index 8de7269..a8a8c10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -384,6 +384,10 @@ fn classify_error(err: &anyhow::Error) -> ExitCode { return ExitCode::Network; } + if has_response_parse_error(err) { + return ExitCode::Error; + } + if has_io_error(err) || looks_like_user_error(err) { return ExitCode::User; } @@ -426,6 +430,14 @@ fn has_reqwest_error(err: &anyhow::Error) -> bool { .any(|source| source.downcast_ref::().is_some()) } +fn has_response_parse_error(err: &anyhow::Error) -> bool { + err.chain().any(|source| { + source + .downcast_ref::() + .is_some() + }) +} + fn has_io_error(err: &anyhow::Error) -> bool { err.chain() .any(|source| source.downcast_ref::().is_some())