opencellid 0.1.0

Rust client library for the OpenCellID API — sync and async clients with tracing, structured errors, and bounded I/O.
Documentation
//! Response parsing: JSON success/error envelope detection, CSV decoding, upload checks.

use serde::Deserialize;

use crate::error::{ApiErrorCode, Error, ParseError, Result, truncate_for_diagnostic};
#[cfg(feature = "csv")]
use crate::types::Cell;

/// JSON error envelope returned by OpenCellID on failure.
///
/// `deny_unknown_fields` keeps this from accidentally matching unrelated payloads
/// that happen to carry an `err` or `error` key with extra siblings.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ApiError {
    #[serde(rename = "err", alias = "error")]
    code: u16,
    #[serde(rename = "msg", alias = "message", default)]
    message: String,
}

/// Inspect a body for the OpenCellID error envelope. Returns `Some(Error::Api)` if it
/// matches, `None` otherwise. The body is parsed exactly once.
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 })
}

/// Parse a JSON body as `T`, returning a mapped [`Error::Api`] on the OpenCellID
/// error envelope. The raw body is preserved on the [`std::error::Error::source`]
/// chain rather than duplicated into the top-level 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,
        ))
    })
}

/// Decode response bytes as UTF-8 and translate any HTTP error to [`Error::Api`].
///
/// Used by both clients after they have read and length-bounded the body. Logs
/// the final `status` and `body_len` at `trace` level — after the status check —
/// so that error paths surface as `Error::Api` rather than misleading
/// "received response" traces.
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)
}

/// Translate an HTTP status into an [`Error::Api`] when 4xx/5xx, otherwise return Ok.
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 })
}

/// Wire-format success markers returned by the upload endpoints.
const UPLOAD_OK_CSV: &str = "0,OK";

/// Validate the body of an upload response.
///
/// OpenCellID returns either `0,OK` (CSV / CLF endpoints) or `{"code":0,"status":"OK"}`
/// (JSON endpoint). A non-zero JSON envelope becomes [`Error::Api`]. Any other shape
/// is returned as [`Error::Parse`] so the caller can decide whether to ignore it.
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('{') {
        // JSON success envelope; structure validated above.
        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)
    ))))
}

/// Parse a CSV response from `cell/getInArea` into a vector of [`Cell`].
#[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() {
        // Successful payload that happens to have unrelated fields — must not be
        // matched as an ApiError because deny_unknown_fields rules it out.
        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);
        // Either it parses successfully or it fails on type mismatch — but it must
        // not become an Api error.
        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!(),
        }
    }
}