# opencellid
A Rust client for the [OpenCellID](https://opencellid.org/) public API — the
largest community-maintained database of cell-tower locations.
The crate wraps the documented [`https://opencellid.org`](https://wiki.opencellid.org/wiki/API)
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
```rust
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:
```toml
[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.
| `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`:
```toml
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
```rust
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
```rust
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
```rust
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
```rust
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:
| `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:
```rust
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:
```sh
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.
```sh
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](https://wiki.opencellid.org/wiki/API) (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
* [OpenCellID API documentation](https://wiki.opencellid.org/wiki/API)
* [`opencellid-client` (JavaScript)](https://github.com/nessche/opencellid-client)
This crate is an independent reimplementation in Rust and is not affiliated
with the OpenCellID project or with Unwired Labs.