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};
#[derive(Debug, Clone, Copy)]
pub(crate) enum Endpoint {
CellGet,
#[cfg(feature = "csv")]
CellGetInArea,
CellGetInAreaSize,
MeasureAdd,
MeasureUploadCsv,
MeasureUploadJson,
MeasureUploadClf,
Downloads,
DownloadsList,
}
#[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",
}
}
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,
}
}
}
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)
}
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)
}
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(())
}
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());
}
}
#[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");
}
}
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");
}
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);
}
}
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}");
}
}