use serde::Deserialize;
use crate::error::{ApiErrorCode, Error, ParseError, Result, truncate_for_diagnostic};
#[cfg(feature = "csv")]
use crate::types::Cell;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ApiError {
#[serde(rename = "err", alias = "error")]
code: u16,
#[serde(rename = "msg", alias = "message", default)]
message: String,
}
pub(crate) fn detect_api_error(body: &str) -> Option<Error> {
let trimmed = body.trim_start();
if !trimmed.starts_with('{') {
return None;
}
let parsed = serde_json::from_str::<ApiError>(body).ok()?;
if parsed.code == 0 {
return None;
}
let code = ApiErrorCode::from_code(parsed.code);
let message = if parsed.message.is_empty() {
code.to_string()
} else {
truncate_for_diagnostic(&parsed.message)
};
Some(Error::Api { code, message })
}
pub(crate) fn parse_json<T: for<'de> Deserialize<'de>>(body: &str) -> Result<T> {
if let Some(err) = detect_api_error(body) {
return Err(err);
}
serde_json::from_str::<T>(body).map_err(|e| {
Error::Parse(ParseError::with_source(
format!("json parse: {}", truncate_for_diagnostic(body)),
e,
))
})
}
pub(crate) fn finalize_response_body(
status: reqwest::StatusCode,
bytes: Vec<u8>,
) -> Result<String> {
let body = String::from_utf8(bytes).map_err(|e| {
Error::Parse(crate::error::ParseError::with_source("response is not valid UTF-8", e))
})?;
check_http_status(status, &body)?;
tracing::trace!(%status, body_len = body.len(), "received response");
Ok(body)
}
pub(crate) fn check_http_status(status: reqwest::StatusCode, body: &str) -> Result<()> {
if status.is_success() {
return Ok(());
}
let code = match status.as_u16() {
400 => ApiErrorCode::InvalidInput,
401 => ApiErrorCode::InvalidApiKey,
403 => ApiErrorCode::NeedsWhitelisting,
429 => ApiErrorCode::DailyLimitExceeded,
500 => ApiErrorCode::ServerError,
503 => ApiErrorCode::TooManyRequests,
other => ApiErrorCode::Unknown(other),
};
let message = if body.is_empty() {
status.canonical_reason().unwrap_or("HTTP error").to_string()
} else {
truncate_for_diagnostic(body)
};
Err(Error::Api { code, message })
}
const UPLOAD_OK_CSV: &str = "0,OK";
pub(crate) fn check_upload_response(body: &str) -> Result<()> {
if let Some(err) = detect_api_error(body) {
return Err(err);
}
let trimmed = body.trim();
if trimmed.starts_with('{') {
return Ok(());
}
if trimmed == UPLOAD_OK_CSV || trimmed.eq_ignore_ascii_case("ok") {
return Ok(());
}
Err(Error::Parse(ParseError::new(format!(
"unexpected upload response: {}",
truncate_for_diagnostic(body)
))))
}
#[cfg(feature = "csv")]
pub(crate) fn parse_cells_csv(body: &str) -> Result<Vec<Cell>> {
if body.is_empty() {
return Ok(Vec::new());
}
if let Some(err) = detect_api_error(body) {
return Err(err);
}
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.flexible(true)
.from_reader(body.as_bytes());
let mut out = Vec::new();
for record in reader.deserialize::<Row>() {
let r = record.map_err(|e| Error::Parse(ParseError::with_source("csv row", e)))?;
out.push(r.into());
}
Ok(out)
}
#[cfg(feature = "csv")]
impl From<Row> for Cell {
fn from(r: Row) -> Self {
Cell {
lat: r.lat,
lon: r.lon,
mcc: r.mcc,
mnc: r.mnc,
lac: r.lac,
cell_id: r.cellid,
range: r.range,
samples: r.samples,
changeable: r.changeable != 0,
avg_signal: r.avg_signal,
radio: r.radio,
}
}
}
#[cfg(feature = "csv")]
#[derive(Debug, Deserialize)]
struct Row {
lat: f64,
lon: f64,
mcc: u16,
mnc: u16,
lac: u32,
cellid: u64,
#[serde(rename = "averageSignalStrength", default)]
avg_signal: i32,
#[serde(default)]
range: u32,
#[serde(default)]
samples: u32,
#[serde(default)]
changeable: u8,
#[serde(default)]
radio: Option<crate::types::Radio>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Cell;
#[test]
fn parse_json_success() {
let body = r#"{"lat":1.0,"lon":2.0,"mcc":250,"mnc":1,"lac":7,"cellid":42,"range":100,"samples":3,"changeable":1,"averageSignalStrength":-95}"#;
let cell: Cell = parse_json(body).unwrap();
assert_eq!(cell.cell_id, 42);
assert_eq!(cell.avg_signal, -95);
assert!(cell.changeable);
}
#[test]
fn parse_json_api_error() {
let body = r#"{"err":2,"msg":"invalid api key"}"#;
let r: Result<Cell> = parse_json(body);
match r.unwrap_err() {
Error::Api { code, message } => {
assert_eq!(code, ApiErrorCode::InvalidApiKey);
assert_eq!(message, "invalid api key");
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn parse_json_alt_keys() {
let body = r#"{"error":7,"message":"daily limit"}"#;
let r: Result<Cell> = parse_json(body);
match r.unwrap_err() {
Error::Api { code, .. } => assert_eq!(code, ApiErrorCode::DailyLimitExceeded),
other => panic!("expected Api, got {other:?}"),
}
}
#[test]
fn parse_json_does_not_misclassify_payloads_with_extra_fields() {
let body = r#"{"err":0,"msg":"","lat":1.0,"lon":2.0,"mcc":1,"mnc":1,"lac":1,"cellid":1}"#;
let cell: Result<Cell> = parse_json(body);
match cell {
Ok(c) => assert_eq!(c.cell_id, 1),
Err(Error::Parse(_)) => {}
Err(other) => panic!("must not be an Api error, got {other:?}"),
}
}
#[test]
fn http_status_maps_codes() {
let r = check_http_status(reqwest::StatusCode::TOO_MANY_REQUESTS, "");
match r.unwrap_err() {
Error::Api { code, .. } => assert_eq!(code, ApiErrorCode::DailyLimitExceeded),
_ => panic!(),
}
}
#[test]
fn http_status_truncates_long_body() {
let body = "x".repeat(2000);
let r = check_http_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR, &body);
match r.unwrap_err() {
Error::Api { message, .. } => {
assert!(message.len() < body.len());
assert!(message.contains("(2000 bytes total)"));
}
_ => panic!(),
}
}
#[test]
fn check_upload_response_accepts_csv_ok() {
check_upload_response("0,OK").unwrap();
}
#[test]
fn check_upload_response_accepts_json_ok() {
check_upload_response(r#"{"code":0,"status":"OK"}"#).unwrap();
}
#[test]
fn check_upload_response_rejects_api_error() {
let r = check_upload_response(r#"{"err":2,"msg":"bad key"}"#);
match r.unwrap_err() {
Error::Api { code, .. } => assert_eq!(code, ApiErrorCode::InvalidApiKey),
_ => panic!(),
}
}
#[cfg(feature = "csv")]
#[test]
fn parse_csv_basic() {
let body = "lat,lon,mcc,mnc,lac,cellid,averageSignalStrength,range,samples,changeable,radio\n\
1.0,2.0,250,1,7,42,-95,100,3,1,LTE\n\
3.5,4.5,250,2,8,43,-100,200,5,0,GSM\n";
let cells = parse_cells_csv(body).unwrap();
assert_eq!(cells.len(), 2);
assert_eq!(cells[0].radio, Some(crate::types::Radio::Lte));
assert!(cells[0].changeable);
assert_eq!(cells[1].cell_id, 43);
assert!(!cells[1].changeable);
}
#[cfg(feature = "csv")]
#[test]
fn parse_csv_empty_returns_empty() {
assert!(parse_cells_csv("").unwrap().is_empty());
}
#[cfg(feature = "csv")]
#[test]
fn parse_csv_propagates_api_error() {
let body = r#"{"err":2,"msg":"bad key"}"#;
let r = parse_cells_csv(body);
match r.unwrap_err() {
Error::Api { code, .. } => assert_eq!(code, ApiErrorCode::InvalidApiKey),
_ => panic!(),
}
}
}