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](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.

| 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`:

```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:

| 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:

```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.