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 ;
async
Installation
Add the dependency:
[]
= "0.1"
= { = "1", = ["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:
= { = "0.1", = false, = ["blocking", "rustls-tls"] }
Description
Client lifecycle
Both clients are constructed through a single ClientBuilder:
Client(async) is the default. Cheap to clone — it wraps anArc<Inner>over a singlereqwest::Clientand its connection pool.BlockingClient(sync) mirrorsClientand is built behind theblockingfeature. Do not invoke its methods from inside a Tokio runtime; usetokio::task::spawn_blockingor 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 Duration;
use ClientBuilder;
let client = new
.api_key
.timeout
.user_agent
.base_url? // 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_urlaccepts 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 ;
let key = new.with_radio;
let cell = client.get_cell.await?;
println!;
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 ;
let bbox = new?;
let count = client.get_cells_in_area_size.await?;
println!;
let cells = client
.get_cells_in_area
.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 ;
let m = new?
.with_signal
.with_measured_at;
client.add_measurement.await?;
let mut batch = new;
batch.measurements.push;
client.upload_json.await?;
// Or as raw CSV / CLF3 bodies:
client.upload_csv.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)). UseApiErrorCode::is_retryableto drive retry middleware.InvalidInput(String)— caller-supplied input failed local validation (e.g., body exceedsMAX_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:
fmt
.with_env_filter
.init;
Levels follow the usual conventions:
debug!for each HTTP request, with the URL emitted using aredact_api_keyhelper that replaces thekey=…query parameter withkey=***.trace!for response status andbody_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:
RUSTDOCFLAGS="-D warnings"
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=…
OPENCELLID_API_KEY=…
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.