opencellid 0.2.0

Rust client library for the OpenCellID API — sync and async clients with tracing, structured errors, and bounded I/O.
Documentation
//! Endpoint definitions and shared query construction.
//!
//! Pure, runtime-agnostic helpers used by both the async and blocking clients.

use url::Url;

use crate::error::{Error, Result};
use crate::params::AreaQuery;
#[cfg(feature = "csv")]
use crate::params::GetCellsInAreaParams;
use crate::types::{CellKey, DumpKind, Measurement};

/// One of the public OpenCellID endpoints used by this client.
#[derive(Debug, Clone, Copy)]
pub(crate) enum Endpoint {
    CellGet,
    #[cfg(feature = "csv")]
    CellGetInArea,
    CellGetInAreaSize,
    MeasureAdd,
    MeasureUploadCsv,
    MeasureUploadJson,
    MeasureUploadClf,
    Downloads,
    DownloadsList,
}

/// Wire-format hint for an upload endpoint.
#[derive(Debug, Clone, Copy)]
pub(crate) struct UploadDescriptor {
    pub filename: &'static str,
    pub mime: &'static str,
}

impl Endpoint {
    pub(crate) fn path(self) -> &'static str {
        match self {
            Self::CellGet => "cell/get",
            #[cfg(feature = "csv")]
            Self::CellGetInArea => "cell/getInArea",
            Self::CellGetInAreaSize => "cell/getInAreaSize",
            Self::MeasureAdd => "measure/add",
            Self::MeasureUploadCsv => "measure/uploadCsv",
            Self::MeasureUploadJson => "measure/uploadJson",
            Self::MeasureUploadClf => "measure/uploadClf",
            Self::Downloads => "ocid/downloads",
            Self::DownloadsList => "downloads.php",
        }
    }

    /// Filename and MIME used on multipart uploads. Returns `None` for non-upload endpoints.
    pub(crate) fn upload_descriptor(self) -> Option<UploadDescriptor> {
        match self {
            Self::MeasureUploadCsv => Some(UploadDescriptor { filename: "data.csv", mime: "text/csv" }),
            Self::MeasureUploadJson => {
                Some(UploadDescriptor { filename: "data.json", mime: "application/json" })
            }
            Self::MeasureUploadClf => Some(UploadDescriptor { filename: "data.clf", mime: "text/plain" }),
            _ => None,
        }
    }
}

/// Build a fully-qualified URL for `endpoint` rooted at `base_url`, with the API key attached.
pub(crate) fn build_url(base_url: &Url, endpoint: Endpoint, api_key: &str) -> Result<Url> {
    let mut url = base_url.join(endpoint.path())?;
    url.query_pairs_mut().append_pair("key", api_key);
    Ok(url)
}

/// Like [`build_url`] but attaches the secret as `token=` instead of `key=`.
///
/// Used for `/ocid/downloads` and `/downloads.php`, which on the server side
/// expect `?token=` while the rest of the API expects `?key=`.
pub(crate) fn build_url_with_token(
    base_url: &Url,
    endpoint: Endpoint,
    token: &str,
) -> Result<Url> {
    let mut url = base_url.join(endpoint.path())?;
    url.query_pairs_mut().append_pair("token", token);
    Ok(url)
}

/// Append `type=` and `file=` query params for `/ocid/downloads` derived from a
/// [`DumpKind`].
///
/// # Errors
///
/// Returns [`Error::InvalidInput`] when the kind cannot produce a valid file
/// name (e.g. `Daily` with a malformed date).
pub(crate) fn add_dump_params(url: &mut Url, kind: &DumpKind) -> Result<()> {
    let (kind_str, file) = kind.type_and_file()?;
    let mut q = url.query_pairs_mut();
    q.append_pair("type", kind_str);
    q.append_pair("file", &file);
    Ok(())
}

/// Append `cell/get` parameters from a [`CellKey`].
pub(crate) fn add_cell_get_params(url: &mut Url, key: &CellKey) {
    let mut q = url.query_pairs_mut();
    append_int(&mut q, "mcc", key.mcc);
    append_int(&mut q, "mnc", key.mnc);
    append_int(&mut q, "lac", key.lac);
    append_int(&mut q, "cellid", key.cell_id);
    if let Some(r) = key.radio {
        q.append_pair("radio", r.as_api_str());
    }
    q.append_pair("format", "json");
}

fn add_area_filters(url: &mut Url, q: &AreaQuery) {
    let mut pairs = url.query_pairs_mut();
    pairs.append_pair("BBOX", &q.bbox.to_query_value());
    if let Some(v) = q.mcc {
        append_int(&mut pairs, "mcc", v);
    }
    if let Some(v) = q.mnc {
        append_int(&mut pairs, "mnc", v);
    }
    if let Some(v) = q.lac {
        append_int(&mut pairs, "lac", v);
    }
    if let Some(r) = q.radio {
        pairs.append_pair("radio", r.as_api_str());
    }
}

/// Append `cell/getInArea` parameters. Always requests CSV format for predictable parsing.
#[cfg(feature = "csv")]
pub(crate) fn add_get_in_area_params(url: &mut Url, p: &GetCellsInAreaParams) {
    add_area_filters(url, &p.query);
    {
        let mut q = url.query_pairs_mut();
        if let Some(limit) = p.limit {
            append_int(&mut q, "limit", limit);
        }
        if let Some(offset) = p.offset {
            append_int(&mut q, "offset", offset);
        }
        q.append_pair("format", "csv");
    }
}

/// Append `cell/getInAreaSize` parameters with JSON format.
pub(crate) fn add_get_in_area_size_params(url: &mut Url, q: &AreaQuery) {
    add_area_filters(url, q);
    url.query_pairs_mut().append_pair("format", "json");
}

/// Append all required and optional parameters for `measure/add`.
pub(crate) fn add_measurement_params(url: &mut Url, m: &Measurement) {
    let mut q = url.query_pairs_mut();
    q.append_pair("lat", &crate::types::format_coordinate(m.lat));
    q.append_pair("lon", &crate::types::format_coordinate(m.lon));
    append_int(&mut q, "mcc", m.mcc);
    append_int(&mut q, "mnc", m.mnc);
    append_int(&mut q, "lac", m.lac);
    append_int(&mut q, "cellid", m.cell_id);
    q.append_pair("act", m.radio.as_api_str());
    append_opt_int(&mut q, "signal", m.signal);
    append_opt_str(&mut q, "measured_at", m.measured_at.as_deref());
    append_opt_int(&mut q, "rating", m.rating);
    append_opt_float(&mut q, "speed", m.speed);
    append_opt_float(&mut q, "direction", m.direction);
    append_opt_int(&mut q, "ta", m.ta);
    append_opt_int(&mut q, "psc", m.psc);
    append_opt_int(&mut q, "tac", m.tac);
    append_opt_int(&mut q, "pci", m.pci);
}

fn append_int<T: ToString>(
    q: &mut url::form_urlencoded::Serializer<'_, url::UrlQuery<'_>>,
    key: &str,
    value: T,
) {
    q.append_pair(key, &value.to_string());
}

fn append_opt_int<T: ToString>(
    q: &mut url::form_urlencoded::Serializer<'_, url::UrlQuery<'_>>,
    key: &str,
    value: Option<T>,
) {
    if let Some(v) = value {
        q.append_pair(key, &v.to_string());
    }
}

fn append_opt_float(
    q: &mut url::form_urlencoded::Serializer<'_, url::UrlQuery<'_>>,
    key: &str,
    value: Option<f32>,
) {
    if let Some(v) = value {
        q.append_pair(key, &v.to_string());
    }
}

fn append_opt_str(
    q: &mut url::form_urlencoded::Serializer<'_, url::UrlQuery<'_>>,
    key: &str,
    value: Option<&str>,
) {
    if let Some(v) = value {
        q.append_pair(key, v);
    }
}

/// Validate that an `endpoint` is an upload endpoint and that `body_len` fits
/// within `max_bytes`. Returns the upload descriptor (filename + MIME).
pub(crate) fn prepare_upload(
    endpoint: Endpoint,
    body_len: usize,
    max_bytes: usize,
) -> Result<UploadDescriptor> {
    if body_len > max_bytes {
        return Err(Error::InvalidInput(format!(
            "upload body is {body_len} bytes, exceeds {max_bytes} byte limit"
        )));
    }
    endpoint.upload_descriptor().ok_or_else(|| {
        Error::InvalidInput(format!("not an upload endpoint: {}", endpoint.path()))
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{Bbox, Radio};

    fn base() -> Url {
        Url::parse("https://opencellid.org/").unwrap()
    }

    #[test]
    fn build_url_attaches_key() {
        let u = build_url(&base(), Endpoint::CellGet, "abc").unwrap();
        assert_eq!(u.path(), "/cell/get");
        assert!(u.query().unwrap().contains("key=abc"));
    }

    #[test]
    fn cell_get_params_include_format_json() {
        let mut u = build_url(&base(), Endpoint::CellGet, "k").unwrap();
        let key = CellKey::new(250, 1, 7800, 12345).with_radio(Radio::Lte);
        add_cell_get_params(&mut u, &key);
        let q = u.query().unwrap();
        assert!(q.contains("mcc=250"));
        assert!(q.contains("mnc=1"));
        assert!(q.contains("lac=7800"));
        assert!(q.contains("cellid=12345"));
        assert!(q.contains("radio=LTE"));
        assert!(q.contains("format=json"));
    }

    #[cfg(feature = "csv")]
    #[test]
    fn area_params_include_bbox_and_csv() {
        let bbox = Bbox::new(10.0, 20.0, 11.0, 21.0).unwrap();
        let p = GetCellsInAreaParams::new(AreaQuery::new(bbox).mcc(250)).limit(10);
        let mut u = build_url(&base(), Endpoint::CellGetInArea, "k").unwrap();
        add_get_in_area_params(&mut u, &p);
        let q = u.query().unwrap();
        assert!(q.contains("BBOX=10%2C20%2C11%2C21"));
        assert!(q.contains("mcc=250"));
        assert!(q.contains("limit=10"));
        assert!(q.contains("format=csv"));
    }

    #[test]
    fn upload_descriptor_present_for_uploads() {
        assert!(Endpoint::MeasureUploadCsv.upload_descriptor().is_some());
        assert!(Endpoint::MeasureUploadJson.upload_descriptor().is_some());
        assert!(Endpoint::MeasureUploadClf.upload_descriptor().is_some());
        assert!(Endpoint::CellGet.upload_descriptor().is_none());
    }

    #[test]
    fn prepare_upload_rejects_oversized() {
        let err = prepare_upload(Endpoint::MeasureUploadCsv, 200, 100).unwrap_err();
        assert!(matches!(err, Error::InvalidInput(_)));
    }

    #[test]
    fn prepare_upload_rejects_non_upload_endpoint() {
        let err = prepare_upload(Endpoint::CellGet, 0, 1).unwrap_err();
        assert!(matches!(err, Error::InvalidInput(_)));
    }

    #[test]
    fn add_measurement_params_includes_optional_fields() {
        use crate::types::{Measurement, Radio};
        let m = Measurement::new(55.7558, 37.6173, 250, 1, 7, 42, Radio::Lte)
            .unwrap()
            .with_signal(-95)
            .with_measured_at("2024-01-02 03:04:05")
            .with_rating(50)
            .with_speed(12.5)
            .with_direction(180.0)
            .with_ta(3)
            .with_psc(127)
            .with_tac(7700)
            .with_pci(64);
        let mut u = build_url(&base(), Endpoint::MeasureAdd, "k").unwrap();
        add_measurement_params(&mut u, &m);
        let q = u.query().unwrap();
        for expected in [
            "lat=55.7558", "lon=37.6173", "mcc=250", "mnc=1", "lac=7", "cellid=42",
            "act=LTE", "signal=-95", "measured_at=2024-01-02",
            "rating=50", "speed=12.5", "direction=180", "ta=3",
            "psc=127", "tac=7700", "pci=64",
        ] {
            assert!(q.contains(expected), "query missing {expected}: {q}");
        }
    }

    #[test]
    fn add_measurement_params_avoids_scientific_notation() {
        use crate::types::{Measurement, Radio};
        let m = Measurement::new(1e-7, 1e-7, 250, 1, 7, 42, Radio::Lte).unwrap();
        let mut u = build_url(&base(), Endpoint::MeasureAdd, "k").unwrap();
        add_measurement_params(&mut u, &m);
        let q = u.query().unwrap();
        assert!(q.contains("lat=0.0000001"), "got {q}");
        assert!(!q.to_lowercase().contains("1e-"), "got {q}");
    }
}