opencellid 0.2.0

Rust client library for the OpenCellID API — sync and async clients with tracing, structured errors, and bounded I/O.
Documentation

opencellid

A Rust client for the OpenCellID public API — the largest community-maintained database of cell-tower locations.

The crate wraps the documented https://opencellid.org HTTP endpoints (cell/get, cell/getInArea, cell/getInAreaSize, measure/add, measure/uploadCsv, measure/uploadJson, measure/uploadClf) behind a typed, builder-driven interface and ships both an asynchronous client (built on reqwest::Client) and a blocking one (built on reqwest::blocking::Client). It is intended as a small, auditable building block for higher-level tools — geolocation services, tower-mapping pipelines, and so on.

Synopsis

use opencellid::{CellKey, ClientBuilder, Radio};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = ClientBuilder::new()
        .api_key(std::env::var("OPENCELLID_API_KEY")?)
        .build()?;

    let cell = client
        .get_cell(CellKey::new(262, 2, 801, 86355).with_radio(Radio::Lte))
        .await?;

    println!("{:.4}, {:.4} (range {} m)", cell.lat, cell.lon, cell.range);
    Ok(())
}

Installation

Add the dependency:

[dependencies]
opencellid = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

The default feature set provides the asynchronous client. The crate is no_std-incompatible and depends on a Tokio-compatible runtime through reqwest. It does not pin a runtime flavour itself.

Cargo features

The default feature set is async + rustls-tls + csv. Everything is additive — async and blocking can coexist in one binary.

Feature Default Effect
async yes Compiles the asynchronous Client and pulls in tokio as an optional dependency.
blocking no Compiles the synchronous BlockingClient (uses reqwest::blocking::Client).
csv yes Enables cell/getInArea (CSV-formatted bulk listing) and CSV row parsing.
rustls-tls yes Selects the rustls TLS backend through reqwest.
native-tls no Selects the platform-native TLS backend through reqwest.

A build with neither async nor blocking is rejected at compile time with compile_error!. Mixing rustls-tls and native-tls is permitted (so that cargo test --all-features and docs.rs succeed) but production users should pick exactly one.

To compile only the blocking client without csv:

opencellid = { version = "0.1", default-features = false, features = ["blocking", "rustls-tls"] }

Description

Client lifecycle

Both clients are constructed through a single ClientBuilder:

  • Client (async) is the default. Cheap to clone — it wraps an Arc<Inner> over a single reqwest::Client and its connection pool.
  • BlockingClient (sync) mirrors Client and is built behind the blocking feature. Do not invoke its methods from inside a Tokio runtime; use tokio::task::spawn_blocking or call from a native thread.

There is no separate Client::builder() / BlockingClient::builder() indirection — ClientBuilder::new() is the canonical entry point and dispatches to the chosen flavour through .build() or .build_blocking().

Builder

use std::time::Duration;
use opencellid::ClientBuilder;

let client = ClientBuilder::new()
    .api_key(std::env::var("OPENCELLID_API_KEY")?)
    .timeout(Duration::from_secs(30))
    .user_agent("my-app/1.0")
    .base_url("https://opencellid.org/")?  // for mirrors and tests
    .build()?;

The API key is stored as Arc<str> and never logged. base_url must end with / so that endpoint paths are joined correctly. Per-request timeout and connect_timeout (10 s by default), pool_idle_timeout and tcp_keepalive are wired through to reqwest.

Security: base_url accepts arbitrary URLs; if you accept it from external configuration, validate the host against an allow-list to prevent the API key from being sent to an attacker-controlled endpoint (SSRF).

Looking up a cell

use opencellid::{CellKey, Radio};

let key = CellKey::new(262, 2, 801, 86355).with_radio(Radio::Lte);
let cell = client.get_cell(key).await?;
println!("{:?}", (cell.lat, cell.lon, cell.range, cell.changeable));

CellKey bundles the four-tuple (mcc, mnc, lac, cell_id) into a struct so that the compiler catches arg-swap mistakes. The optional radio hint disambiguates cells that share identifiers across technologies.

Cells in an area

use opencellid::{AreaQuery, Bbox, GetCellsInAreaParams};

let bbox  = Bbox::new(55.5, 37.3, 55.9, 37.8)?;
let count = client.get_cells_in_area_size(AreaQuery::new(bbox).mcc(250)).await?;
println!("{} cells in bbox", count.count);

let cells = client
    .get_cells_in_area(
        GetCellsInAreaParams::new(AreaQuery::new(bbox).mcc(250)).limit(50)
    )
    .await?;

Bbox::new validates that all coordinates are finite and that lat_min < lat_max && lon_min < lon_max. Bulk listings request the CSV format on the wire (server cap is 50 cells per call) and are parsed into typed Vec<Cell> values. Each cell returned counts as one OpenCellID API credit.

Submitting measurements

use opencellid::{Measurement, MeasurementsPayload, Radio};

let m = Measurement::new(55.7558, 37.6173, 250, 1, 7, 42, Radio::Lte)?
    .with_signal(-95)
    .with_measured_at("2024-01-02 03:04:05");

client.add_measurement(&m).await?;

let mut batch = MeasurementsPayload::new();
batch.measurements.push(m);
client.upload_json(&batch).await?;

// Or as raw CSV / CLF3 bodies:
client.upload_csv(b"mcc,mnc,lac,cellid,lon,lat,signal,measured_at,act\n\
                   250,1,7,42,37.6,55.7,-95,2024-01-02 03:04:05,LTE\n".to_vec()).await?;

Measurement::new rejects non-finite lat/lon upfront. upload_json caps the batch at 8 000 entries and validates every measurement before serialising. All upload endpoints enforce the OpenCellID 2 MiB body limit before opening a connection.

Submission is non-idempotent on the server side. Dropping the future after send() has begun does not roll back the operation. Treat as at-least-once.

Error type

Error is #[non_exhaustive]. Variants of interest:

  • Transport(TransportError) — DNS, TLS, connect, timeout, body read.
  • Url(UrlError) — base-URL or endpoint-URL construction failed.
  • Parse(ParseError) — JSON, CSV, or response decoding failed.
  • Api { code: ApiErrorCode, message } — structured server error (CellNotFound, InvalidApiKey, InvalidInput, NeedsWhitelisting, ServerError, TooManyRequests, DailyLimitExceeded, Unknown(u16)). Use ApiErrorCode::is_retryable to drive retry middleware.
  • InvalidInput(String) — caller-supplied input failed local validation (e.g., body exceeds MAX_UPLOAD_BYTES, batch exceeds the per-upload limit, non-finite measurement field).
  • MissingConfig(&'static str) — required builder field was omitted.

External library types (reqwest::Error, url::ParseError, serde_json::Error, csv::Error, std::io::Error) are wrapped in opaque TransportError / UrlError / ParseError structs and reachable via std::error::Error::source, so the crate is not coupled to specific dependency versions in its public surface.

Response bodies included in error messages are length-bounded to 512 bytes and escaped with escape_debug — log injection through hostile newlines or ANSI escapes is prevented.

Limits

The following constants enforce upper bounds:

Constant Value Meaning
MAX_UPLOAD_BYTES 2 MiB Server-imposed cap on multipart upload body size.
MAX_RESPONSE_BYTES 8 MiB Defensive cap on response bodies the client will buffer.
MAX_MEASUREMENTS_PER_UPLOAD 8 000 Cap on MeasurementsPayload::measurements.len().
ERROR_BODY_LIMIT 512 B Response body slice included in Error messages.

Caps are enforced both before reading the body (via Content-Length when present) and after.

Logging

All HTTP operations emit events through the tracing crate. The recommended setup is:

tracing_subscriber::fmt()
    .with_env_filter("opencellid=debug")
    .init();

Levels follow the usual conventions:

  • debug! for each HTTP request, with the URL emitted using a redact_api_key helper that replaces the key=… query parameter with key=***.
  • trace! for response status and body_len (only after status is checked, so success/failure ordering in the trace matches reality).

Debug for ClientConfig is implemented manually to redact the API key — format!("{:?}", client) does not leak it. There are no unsafe blocks in the crate (unsafe_code = "forbid" in Cargo.toml).

Building and testing

The crate compiles cleanly under default and per-feature builds:

cargo build
cargo build --no-default-features --features async,rustls-tls
cargo build --no-default-features --features blocking,rustls-tls
cargo build --all-features

cargo clippy --all-targets --all-features -- -D warnings
cargo test   --all-features
RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps

The integration tests in tests/integration.rs use wiremock and require no network access. Property-based tests for Bbox invariants are driven by proptest. There are no live-API tests for the upload endpoints — they would pollute the public OpenCellID database.

Examples

Two runnable examples are included.

OPENCELLID_API_KEY= cargo run --example get_cell -- 262 2 801 86355
OPENCELLID_API_KEY= cargo run --example get_cell_blocking \
    --no-default-features --features blocking,rustls-tls

The examples set up a tracing_subscriber::fmt subscriber filtered to opencellid=debug so that the redacted request URL appears in the logs.

Status and stability

The crate is at version 0.1 and the public surface should still be considered subject to change. Public enums and structs that may grow new fields or variants over time — Error, ApiErrorCode, Cell, CellCount, CellKey, Measurement, MeasurementsPayload, Radio, TransportError, UrlError, ParseError — are marked #[non_exhaustive]; new additions should not require a major version bump on their own.

The wire types target the OpenCellID API as documented on the wiki (last revised 2025). Any change on the upstream side may require a parser update.

License

Licensed under either of

  • Apache License, Version 2.0
  • MIT license

at your option.

See also

This crate is an independent reimplementation in Rust and is not affiliated with the OpenCellID project or with Unwired Labs.