summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--Cargo.lock386
-rw-r--r--Cargo.toml25
-rw-r--r--LICENSE15
-rw-r--r--Makefile48
-rw-r--r--README.md201
-rw-r--r--src/bin/cepis.rs100
-rw-r--r--src/client.rs559
-rw-r--r--src/csv.rs99
-rw-r--r--src/error.rs150
-rw-r--r--src/lib.rs100
-rw-r--r--src/model.rs227
-rw-r--r--src/retry.rs143
-rw-r--r--tests/api.rs643
14 files changed, 2699 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b7141ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/target
+**/*.rs.bk
+*.pdb
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..1606553
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,386 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cepis"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+ "ureq",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom",
+ "libc",
+ "untrusted",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
+dependencies = [
+ "base64",
+ "log",
+ "percent-encoding",
+ "rustls",
+ "rustls-pki-types",
+ "ureq-proto",
+ "utf8-zero",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
+dependencies = [
+ "base64",
+ "http",
+ "httparse",
+ "log",
+]
+
+[[package]]
+name = "utf8-zero"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..7a68c24
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "cepis"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.85"
+description = "Client for the public CEP API at cep.is (official Correios/DNE data)"
+authors = ["Murilo Ijanc <[email protected]>"]
+license = "ISC"
+readme = "README.md"
+homepage = "https://cep.is"
+repository = "https://git.cep.is/rust.git"
+keywords = ["cep", "correios", "brazil", "address", "postcode"]
+categories = ["api-bindings"]
+
+[dependencies]
+serde_json = "1.0"
+ureq = { version = "3.3", default-features = false, features = ["rustls"] }
+
+[[bin]]
+name = "cepis"
+path = "src/bin/cepis.rs"
+
+[lib]
+name = "cepis"
+path = "src/lib.rs"
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..4afcb19
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,48 @@
+# Development tasks. Everything runs through `cargo`, so there is nothing to
+# install first.
+#
+# make list the targets
+# make test run the suite
+# make lint clippy + rustfmt check, no files touched
+# make build release build of the library and the CLI
+
+CARGO ?= cargo
+
+.DEFAULT_GOAL := help
+.PHONY: help test lint format build check clean doc
+
+help:
+ @echo 'Targets:'
+ @echo ' test run the test suite (unit, integration and doc tests)'
+ @echo ' lint cargo clippy -D warnings + cargo fmt --check (read-only)'
+ @echo ' format apply cargo fmt (writes)'
+ @echo ' build release build of the library and the cepis CLI'
+ @echo ' check lint + test + build, in that order'
+ @echo ' doc build the API documentation'
+ @echo ' clean remove build artefacts'
+
+test:
+ $(CARGO) test --all-targets
+ $(CARGO) test --doc
+
+# Read-only by design, so it is safe in CI and in a pre-commit hook: it reports
+# what `make format` would change without changing anything.
+lint:
+ $(CARGO) clippy --all-targets -- -D warnings
+ $(CARGO) fmt --check
+
+format:
+ $(CARGO) fmt
+
+build:
+ $(CARGO) build --release
+
+doc:
+ $(CARGO) doc --no-deps
+
+# The order matters: lint is the fastest to fail, build the slowest, so a broken
+# run reports the cheapest problem first.
+check: lint test build
+
+clean:
+ $(CARGO) clean
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8481710
--- /dev/null
+++ b/README.md
@@ -0,0 +1,201 @@
+# cepis
+
+Rust client for the [public CEP API at cep.is](https://cep.is). Official
+Correios data (DNE), refreshed every two weeks.
+
+```rust
+let address = cepis::lookup("01001-000")?;
+println!("{} {} {}", address.logradouro, address.localidade, address.uf);
+// Praça da Sé São Paulo SP
+```
+
+## Installation
+
+```toml
+[dependencies]
+cepis = { git = "https://git.cep.is/rust.git" }
+```
+
+Requires Rust 1.85 or newer. Two dependencies: `ureq` — the standard library
+has no HTTP client, and `ureq` is the smallest established blocking one, with
+TLS through `rustls` and no async runtime — and `serde_json`.
+
+## Usage
+
+### A single lookup
+
+`lookup` accepts the CEP masked or unmasked and returns an `Address`:
+
+```rust
+let a = cepis::lookup("01310-100")?;
+
+a.logradouro; // "Avenida Paulista"
+a.localidade; // "São Paulo"
+a.uf; // "SP"
+a.ibge; // "3550308"
+a.cep; // "01310100" (always unmasked)
+a.formatted_cep(); // "01310-100"
+```
+
+Fields the database does not have for that CEP come back as empty strings.
+`a.raw` holds the response exactly as it arrived, so a field the API grows
+before this crate is updated is not lost.
+
+### Coordinates
+
+About 57% of CEPs have a coordinate. When they do not, `geo` is `None`:
+
+```rust
+if let Some(geo) = &a.geo {
+ println!("{} {}", geo.lat, geo.lon); // -23.567202 -46.649659
+ println!("{}", geo.raio_m); // 1084
+ println!("{}", geo.precisao); // "logradouro"
+}
+```
+
+**Check `precisao` before computing distance.** There are two kinds of point,
+and the difference between them is tens of kilometres:
+
+| `precisao` | typical radius | what the point is |
+| --- | --- | --- |
+| `logradouro` | 110–140 m | the stretch of street — good for maps and distance |
+| `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city |
+
+`geo.is_precise()` is the shorthand for `precisao == "logradouro"`:
+
+```rust
+let cost = match &a.geo {
+ Some(geo) if geo.is_precise() => shipping_from(geo.lat, geo.lon),
+ _ => shipping_from_municipality(&a.ibge),
+};
+```
+
+### Many CEPs at once
+
+Do not loop over `lookup`. Use `lookup_many`, which resolves up to 500 CEPs per
+request:
+
+```rust
+let ceps = ["01001000", "99999999", "20040020"];
+
+for (requested, address) in ceps.iter().zip(cepis::lookup_many(&ceps)?) {
+ match address {
+ Some(a) => println!("{requested}: {}", a.localidade),
+ None => println!("{requested}: not found"),
+ }
+}
+```
+
+The returned `Vec` has **the same length and the same order** as the input. A
+CEP that does not exist, or is malformed, becomes `None` at its position — it
+does not vanish, and it is not an error.
+
+Slices longer than 500 are chunked automatically.
+
+> **Batches carry no coordinate.** The endpoint behind them is CSV, and the CSV
+> has no `geo` column. Every `Address` from `lookup_many` has `geo: None`,
+> including for CEPs that do have one. Need `geo`? Use `lookup`, one at a time.
+
+### API health
+
+Useful for two things: checking the API answers, and knowing **which version of
+the database** is live:
+
+```rust
+let h = cepis::health()?;
+
+h.ok(); // true
+h.dne; // "26071" identifier of the fortnightly database
+h.ceps; // 1596168
+h.base_gerada_em; // "2026-07-24T03:48:28Z"
+```
+
+An API reporting itself degraded is not an error — that arrives in `h.status`.
+
+## Configuration
+
+For anything beyond the defaults, build a `Client` and reuse it. It keeps no
+state between calls, is safe to share across threads, and pools connections:
+
+```rust
+use std::time::Duration;
+
+let client = cepis::Client::builder()
+ .base_url("https://cep.is") // point at another instance
+ .timeout(Duration::from_secs(10)) // per socket operation
+ .max_attempts(3) // counting the first; 1 disables retrying
+ .backoff_base(Duration::from_millis(500)) // base of the exponential wait
+ .backoff_max(Duration::from_secs(8)) // cap on each wait
+ .total_timeout(Some(Duration::from_secs(30))) // cap on the whole call
+ .user_agent("my-app/1.0")
+ .build();
+
+let address = client.lookup("01001000")?;
+```
+
+Retries cover 429, 5xx and network failures, with exponential backoff and
+jitter; 400 and 404 are never retried.
+
+The free functions (`cepis::lookup`, and so on) share one default client, built
+on first use. Need a different configuration? Build your own.
+
+### Pointing at a local instance
+
+`base_url` is the only setting that usually changes between environments. The
+API repo brings a local instance up with `just up`:
+
+```rust
+let client = cepis::Client::builder()
+ .base_url("http://localhost:8080")
+ .build();
+```
+
+From the command line, to confirm quickly that it answers:
+
+```sh
+cargo run --bin cepis -- --base-url http://localhost:8080 01001000
+```
+
+## Errors
+
+Everything is one `Error`. Its variants split by **the decision you have to
+make**, not by the HTTP status that caused it:
+
+| variant | when | does retrying help? |
+| --- | --- | --- |
+| `InvalidCep` | not 8 digits | no |
+| `NotFound` | well-formed, not in the database | no |
+| `RateLimited` | 429; carries `retry_after` | yes, later |
+| `Server` | 5xx; carries `status` | yes, later |
+| `Network` | DNS, connection, timeout, TLS | yes, later |
+| `InvalidResponse` | unintelligible response | probably not |
+
+```rust
+match cepis::lookup(&cep_from_form) {
+ Ok(address) => render(address),
+ Err(cepis::Error::NotFound { .. }) => ask_for_another_cep(),
+ Err(err) if err.is_retriable() => reschedule(),
+ Err(err) => eprintln!("{err}"),
+}
+```
+
+`InvalidResponse` almost always means something between you and the API is
+answering instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML
+error page. Useful as a diagnosis: the problem is not in your code.
+
+## Development
+
+Everything runs through `make`, which shells out to `cargo`:
+
+```sh
+make # list the targets
+make test # cargo test
+make lint # clippy + rustfmt check, touches nothing
+make format # apply rustfmt (writes)
+make build # release build
+make check # lint, then test, then build
+```
+
+## Licence
+
+ISC. See [LICENSE](LICENSE).
diff --git a/src/bin/cepis.rs b/src/bin/cepis.rs
new file mode 100644
index 0000000..252cb90
--- /dev/null
+++ b/src/bin/cepis.rs
@@ -0,0 +1,100 @@
+//! Minimal CLI: `cepis 01001000`.
+//!
+//! It exists for one concrete case — quickly confirming a local instance
+//! answers before pointing code at it:
+//!
+//! ```sh
+//! cepis --base-url http://localhost:8080 01001000
+//! ```
+//!
+//! A single CEP goes through the unit lookup, which carries `geo`; several go
+//! through the batch, which is one request instead of N and has no `geo`.
+//!
+//! It is not the library's main interface; for that, use the `cepis` crate.
+
+use std::process::ExitCode;
+use std::time::Duration;
+
+use cepis::{Client, DEFAULT_URL};
+use serde_json::Value;
+
+const USAGE: &str = "\
+usage: cepis [--base-url URL] [--timeout SECONDS] CEP [CEP ...]
+
+Look CEPs up against the cep.is API.
+
+ --base-url URL API URL (default: https://cep.is) — point it at a local instance
+ --timeout SECONDS per-socket timeout (default: 10)
+ -h, --help show this message";
+
+fn main() -> ExitCode {
+ let mut base_url = DEFAULT_URL.to_owned();
+ let mut timeout = Duration::from_secs(10);
+ let mut ceps: Vec<String> = Vec::new();
+ let mut args = std::env::args().skip(1);
+
+ while let Some(arg) = args.next() {
+ match arg.as_str() {
+ "-h" | "--help" => {
+ println!("{USAGE}");
+ return ExitCode::SUCCESS;
+ }
+ "--base-url" => match args.next() {
+ Some(value) => base_url = value,
+ None => return fail("--base-url needs a URL"),
+ },
+ "--timeout" => match args.next().map(|v| v.parse::<f64>()) {
+ Some(Ok(seconds)) if seconds > 0.0 => timeout = Duration::from_secs_f64(seconds),
+ _ => return fail("--timeout needs a positive number of seconds"),
+ },
+ other if other.starts_with('-') => return fail(&format!("unknown option {other}")),
+ other => ceps.push(other.to_owned()),
+ }
+ }
+
+ if ceps.is_empty() {
+ eprintln!("{USAGE}");
+ return ExitCode::FAILURE;
+ }
+
+ let client = Client::builder()
+ .base_url(base_url)
+ .timeout(timeout)
+ .build();
+
+ let addresses = if ceps.len() == 1 {
+ match client.lookup(&ceps[0]) {
+ Ok(address) => vec![Some(address)],
+ Err(err) => return fail(&err.to_string()),
+ }
+ } else {
+ match client.lookup_many(&ceps) {
+ Ok(found) => found,
+ Err(err) => return fail(&err.to_string()),
+ }
+ };
+
+ let output: Vec<Value> = addresses
+ .into_iter()
+ .map(|a| a.map(|a| a.raw).unwrap_or(Value::Null))
+ .collect();
+
+ let rendered = if output.len() == 1 {
+ serde_json::to_string_pretty(&output[0])
+ } else {
+ serde_json::to_string_pretty(&output)
+ };
+
+ match rendered {
+ Ok(text) => {
+ println!("{text}");
+ ExitCode::SUCCESS
+ }
+ Err(err) => fail(&err.to_string()),
+ }
+}
+
+fn fail(message: &str) -> ExitCode {
+ eprintln!("error: {message}");
+ ExitCode::FAILURE
+}
diff --git a/src/client.rs b/src/client.rs
new file mode 100644
index 0000000..8d73f06
--- /dev/null
+++ b/src/client.rs
@@ -0,0 +1,559 @@
+//! The HTTP client.
+//!
+//! Retry policy: 429 and the transient 5xx in [`RETRIABLE_STATUS`], plus
+//! network failures, are retried with exponentially backed-off, jittered waits.
+//! 400 and 404 are deliberately absent from that set — both are definitive
+//! answers about the request, so retrying returns the same thing more slowly
+//! while burning someone else's quota.
+
+use std::time::{Duration, Instant};
+
+use serde_json::{Map, Value};
+
+use crate::csv;
+use crate::error::{Error, Result};
+use crate::model::{Address, Health};
+use crate::retry::{jitter, parse_retry_after};
+
+/// Where the public API lives.
+pub const DEFAULT_URL: &str = "https://cep.is";
+
+/// How many CEPs the batch endpoint accepts in one request.
+pub const MAX_BATCH: usize = 500;
+
+/// The statuses worth trying again: rate limiting and the transient 5xx.
+pub const RETRIABLE_STATUS: [u16; 5] = [429, 500, 502, 503, 504];
+
+const ACCEPT: &str = "application/json, text/csv;q=0.9, */*;q=0.1";
+
+/// Return the CEP's 8 digits, accepting the mask.
+///
+/// `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`.
+///
+/// Fails with [`Error::InvalidCep`] unless exactly 8 digits remain. This runs
+/// **before** any request: a 7-digit CEP is the caller's bug, and spending a
+/// round trip for the server to say so wastes both sides' time.
+///
+/// ```
+/// assert_eq!(cepis::normalize_cep("01001-000").unwrap(), "01001000");
+/// assert!(cepis::normalize_cep("123").is_err());
+/// ```
+pub fn normalize_cep(value: &str) -> Result<String> {
+ let cleaned: String = value
+ .trim()
+ .chars()
+ .filter(|c| !matches!(c, '-' | '.' | ' '))
+ .collect();
+
+ if cleaned.len() == 8 && cleaned.bytes().all(|b| b.is_ascii_digit()) {
+ Ok(cleaned)
+ } else {
+ Err(Error::invalid_cep(format!(
+ "CEP must be 8 digits, got {value:?}"
+ )))
+ }
+}
+
+/// Builder for a [`Client`].
+///
+/// Every value has a default; set only what differs. See [`Client`] for what
+/// each one means.
+#[derive(Debug, Clone)]
+pub struct ClientBuilder {
+ base_url: String,
+ timeout: Duration,
+ max_attempts: u32,
+ backoff_base: Duration,
+ backoff_max: Duration,
+ total_timeout: Option<Duration>,
+ user_agent: String,
+}
+
+impl Default for ClientBuilder {
+ fn default() -> Self {
+ ClientBuilder {
+ base_url: DEFAULT_URL.to_owned(),
+ timeout: Duration::from_secs(10),
+ max_attempts: 3,
+ backoff_base: Duration::from_millis(500),
+ backoff_max: Duration::from_secs(8),
+ total_timeout: None,
+ user_agent: format!("cepis-rust/{} (+https://cep.is)", crate::VERSION),
+ }
+ }
+}
+
+impl ClientBuilder {
+ /// Where the API lives.
+ ///
+ /// Point it at a local instance (the API repo brings one up on
+ /// `http://localhost:8080` with `just up`) or at a test server. It is the
+ /// only setting that usually changes between environments. A trailing slash
+ /// is stripped, so it cannot produce doubled separators that some proxies
+ /// normalise and others 404.
+ pub fn base_url(mut self, value: impl Into<String>) -> Self {
+ self.base_url = value.into();
+ self
+ }
+
+ /// How long to wait **per socket operation** (connect, and each read).
+ ///
+ /// This is not a cap on the whole call — that cap is
+ /// [`total_timeout`](ClientBuilder::total_timeout).
+ pub fn timeout(mut self, value: Duration) -> Self {
+ self.timeout = value;
+ self
+ }
+
+ /// How many attempts in total, counting the first.
+ ///
+ /// `1` disables retrying. The default of `3` covers the common case (one
+ /// transient failure) without turning a real outage into a long wait.
+ pub fn max_attempts(mut self, value: u32) -> Self {
+ self.max_attempts = value;
+ self
+ }
+
+ /// Base of the exponential wait.
+ ///
+ /// The actual wait is drawn from `[0, base * 2^(n-1)]` — *full jitter*. The
+ /// randomness is not decoration: without it, N clients that fail together
+ /// come back together and reproduce the spike that took the service down.
+ pub fn backoff_base(mut self, value: Duration) -> Self {
+ self.backoff_base = value;
+ self
+ }
+
+ /// Cap on each individual wait.
+ ///
+ /// It applies to a server-sent `Retry-After` too, or a `Retry-After: 3600`
+ /// from a misconfigured proxy would park the thread for an hour.
+ pub fn backoff_max(mut self, value: Duration) -> Self {
+ self.backoff_max = value;
+ self
+ }
+
+ /// Cap on the whole call, attempts and waits included.
+ ///
+ /// `None` leaves [`max_attempts`](ClientBuilder::max_attempts) as the only
+ /// bound. Set it in code serving a web request: without it the worst case
+ /// is `max_attempts * timeout` plus the sum of the backoffs.
+ pub fn total_timeout(mut self, value: Option<Duration>) -> Self {
+ self.total_timeout = value;
+ self
+ }
+
+ /// Override the `User-Agent`.
+ ///
+ /// The default identifies the library and its version, which is what lets
+ /// the server diagnose an old client misbehaving.
+ pub fn user_agent(mut self, value: impl Into<String>) -> Self {
+ self.user_agent = value.into();
+ self
+ }
+
+ /// Build the client.
+ ///
+ /// # Panics
+ ///
+ /// If `max_attempts` is 0 or `timeout` is zero — both are configuration
+ /// mistakes that can only produce a client which never completes a call.
+ pub fn build(self) -> Client {
+ assert!(self.max_attempts >= 1, "max_attempts must be >= 1");
+ assert!(!self.timeout.is_zero(), "timeout must be > 0");
+
+ let config = ureq::Agent::config_builder()
+ .http_status_as_error(false)
+ .user_agent(self.user_agent.as_str())
+ .accept(ACCEPT)
+ .accept_encoding("identity")
+ .timeout_connect(Some(self.timeout))
+ .timeout_send_request(Some(self.timeout))
+ .timeout_recv_response(Some(self.timeout))
+ .timeout_recv_body(Some(self.timeout))
+ .build();
+
+ Client {
+ base_url: self.base_url.trim_end_matches('/').to_owned(),
+ timeout: self.timeout,
+ max_attempts: self.max_attempts,
+ backoff_base: self.backoff_base,
+ backoff_max: self.backoff_max,
+ total_timeout: self.total_timeout,
+ user_agent: self.user_agent,
+ agent: ureq::Agent::new_with_config(config),
+ }
+ }
+}
+
+/// Client for the cep.is API.
+///
+/// Build one and reuse it: the configuration is immutable, no method keeps
+/// state between calls, and the underlying connection pool is shared — so a
+/// second lookup on the same host skips the TLS handshake.
+///
+/// ```no_run
+/// use std::time::Duration;
+///
+/// let client = cepis::Client::builder()
+/// .timeout(Duration::from_secs(5))
+/// .total_timeout(Some(Duration::from_secs(15)))
+/// .build();
+///
+/// let address = client.lookup("01001000")?;
+/// println!("{} {}", address.localidade, address.uf);
+/// # Ok::<_, cepis::Error>(())
+/// ```
+#[derive(Debug, Clone)]
+pub struct Client {
+ base_url: String,
+ timeout: Duration,
+ max_attempts: u32,
+ backoff_base: Duration,
+ backoff_max: Duration,
+ total_timeout: Option<Duration>,
+ user_agent: String,
+ agent: ureq::Agent,
+}
+
+impl Default for Client {
+ fn default() -> Self {
+ Client::new()
+ }
+}
+
+impl Client {
+ /// A client with the default configuration, pointing at <https://cep.is>.
+ pub fn new() -> Client {
+ ClientBuilder::default().build()
+ }
+
+ /// Start configuring a client.
+ pub fn builder() -> ClientBuilder {
+ ClientBuilder::default()
+ }
+
+ /// The API URL in use, without a trailing slash.
+ pub fn base_url(&self) -> &str {
+ &self.base_url
+ }
+
+ /// The `User-Agent` sent with every request.
+ pub fn user_agent(&self) -> &str {
+ &self.user_agent
+ }
+
+ /// The per-socket-operation timeout.
+ pub fn timeout(&self) -> Duration {
+ self.timeout
+ }
+
+ /// How many attempts each call makes at most, counting the first.
+ pub fn max_attempts(&self) -> u32 {
+ self.max_attempts
+ }
+
+ /// The cap on the whole call, if one is set.
+ pub fn total_timeout(&self) -> Option<Duration> {
+ self.total_timeout
+ }
+
+ /// Look up a single CEP.
+ ///
+ /// The CEP may be masked or not. This is the only way to obtain a
+ /// coordinate — see [`lookup_many`](Client::lookup_many) for why.
+ ///
+ /// A 400 from the API is surfaced as [`Error::InvalidCep`] carrying the
+ /// API's own message: the format already passed local validation, so the
+ /// server knows something this client does not.
+ ///
+ /// # Errors
+ ///
+ /// - [`Error::InvalidCep`] — not 8 digits (no request is made).
+ /// - [`Error::NotFound`] — well-formed, but not in the database.
+ /// - [`Error::RateLimited`] — 429 after every attempt.
+ /// - [`Error::Server`] — 5xx after every attempt.
+ /// - [`Error::Network`] — the request never completed.
+ /// - [`Error::InvalidResponse`] — the body could not be read as JSON.
+ pub fn lookup(&self, cep: &str) -> Result<Address> {
+ let number = normalize_cep(cep)?;
+ let (status, body) = self.request(&format!("/{number}"), None, &[200])?;
+
+ if status == 404 {
+ return Err(Error::NotFound { cep: number });
+ }
+ if status == 400 {
+ return Err(Error::invalid_cep(error_message(
+ &body,
+ &format!("CEP {number} rejected"),
+ )));
+ }
+
+ Ok(Address::from_json(json_object(&body)?))
+ }
+
+ /// Look up many CEPs in one batch.
+ ///
+ /// Returns a `Vec` of **the same length and in the same order** as the
+ /// input. A CEP that does not exist becomes `None` at its position — it
+ /// does not vanish from the list, and it is not an error. That is the right
+ /// difference from [`lookup`](Client::lookup): in a batch of a thousand,
+ /// one bad CEP must not cost the other nine hundred and ninety-nine.
+ ///
+ /// A malformed CEP also becomes `None`, for the same reason. If you need to
+ /// know *which* one was malformed, validate first with
+ /// [`normalize_cep`].
+ ///
+ /// Input is chunked into blocks of [`MAX_BATCH`] automatically. Do not loop
+ /// over [`lookup`](Client::lookup) instead: 500 calls pay 500 round trips,
+ /// the batch pays one.
+ ///
+ /// # Warning
+ ///
+ /// The batch carries **no coordinate**. The endpoint behind it is CSV, and
+ /// the CSV has no `geo` column. Every [`Address`] returned here has
+ /// `geo: None`, including for CEPs that do have one. Need `geo`? Use
+ /// [`lookup`](Client::lookup), one at a time.
+ ///
+ /// # Errors
+ ///
+ /// Transport failures only — [`Error::RateLimited`], [`Error::Server`],
+ /// [`Error::Network`], [`Error::InvalidResponse`]. A bad CEP never fails a
+ /// batch.
+ pub fn lookup_many(&self, ceps: &[impl AsRef<str>]) -> Result<Vec<Option<Address>>> {
+ if ceps.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let mut result = Vec::with_capacity(ceps.len());
+ for chunk in ceps.chunks(MAX_BATCH) {
+ result.extend(self.lookup_chunk(chunk)?);
+ }
+ Ok(result)
+ }
+
+ /// Query `GET /health`: does the API answer, and with which database.
+ ///
+ /// An API reporting itself degraded is not an error — that arrives in
+ /// [`Health::status`], and checking health is exactly when you want the
+ /// diagnosis rather than a failure. Network failures still fail.
+ pub fn health(&self) -> Result<Health> {
+ let (_, body) = self.request("/health", None, &[200, 503])?;
+ Ok(Health::from_json(json_object(&body)?))
+ }
+
+ /// One chunk of at most [`MAX_BATCH`] CEPs, in a single request.
+ ///
+ /// A blank CSV row means "not found": the API fills only the `cep` column
+ /// and leaves the rest empty, precisely so it does not misalign whoever is
+ /// consuming the batch in a spreadsheet.
+ ///
+ /// The API guarantees one row per requested CEP. Should that ever fail to
+ /// hold — a proxy truncating, a cut response — the result is padded to the
+ /// input length rather than returned short, because a shifted list silently
+ /// pairs the wrong address with the wrong CEP.
+ fn lookup_chunk(&self, ceps: &[impl AsRef<str>]) -> Result<Vec<Option<Address>>> {
+ let joined = ceps
+ .iter()
+ .map(|c| c.as_ref())
+ .collect::<Vec<_>>()
+ .join(",");
+ let (_, body) = self.request("/csv", Some(("ceps", joined)), &[200])?;
+
+ let text = String::from_utf8_lossy(&body);
+ let records = csv::parse(&text);
+ let mut rows: Vec<Option<Address>> = Vec::with_capacity(ceps.len());
+
+ if let Some((header, data)) = records.split_first() {
+ for record in data {
+ let mut object = Map::new();
+ for (column, value) in header.iter().zip(record.iter()) {
+ object.insert(column.clone(), Value::String(value.clone()));
+ }
+ let known = object
+ .get("localidade")
+ .and_then(Value::as_str)
+ .is_some_and(|v| !v.trim().is_empty());
+ rows.push(known.then(|| Address::from_json(Value::Object(object))));
+ }
+ }
+
+ rows.resize(ceps.len(), None);
+ Ok(rows)
+ }
+
+ /// Perform the GET, with retries. Returns `(status, body)`.
+ ///
+ /// *Definitive* error statuses (400, 404) come back normally, for the
+ /// caller to turn into the right error — only transient failures become
+ /// errors here, and only once the attempts are exhausted.
+ fn request(
+ &self,
+ path: &str,
+ query: Option<(&str, String)>,
+ accepted: &[u16],
+ ) -> Result<(u16, Vec<u8>)> {
+ let url = format!("{}{}", self.base_url, path);
+ let deadline = self.total_timeout.map(|cap| Instant::now() + cap);
+
+ let mut last_status: Option<u16> = None;
+ let mut last_retry_after: Option<Duration> = None;
+ let mut last_network: Option<ureq::Error> = None;
+
+ for attempt in 1..=self.max_attempts {
+ let remaining = match deadline {
+ Some(d) => {
+ let left = d.saturating_duration_since(Instant::now());
+ if left.is_zero() {
+ break;
+ }
+ Some(left)
+ }
+ None => None,
+ };
+
+ let mut request = self.agent.get(&url);
+ if let Some((key, value)) = &query {
+ request = request.query(key, value);
+ }
+ if let Some(left) = remaining {
+ request = request.config().timeout_global(Some(left)).build();
+ }
+
+ match request.call() {
+ Ok(mut response) => {
+ let status = response.status().as_u16();
+ let retry_after = response
+ .headers()
+ .get("retry-after")
+ .and_then(|v| v.to_str().ok())
+ .and_then(parse_retry_after);
+
+ match response.body_mut().read_to_vec() {
+ Ok(body) => {
+ if accepted.contains(&status) || !RETRIABLE_STATUS.contains(&status) {
+ return Ok((status, body));
+ }
+ last_status = Some(status);
+ last_retry_after = retry_after;
+ last_network = None;
+ }
+ Err(err) => {
+ last_status = None;
+ last_retry_after = None;
+ last_network = Some(err);
+ }
+ }
+ }
+ Err(ureq::Error::BadUri(detail)) => {
+ return Err(Error::network(
+ format!("{url} is not a usable URL: {detail}"),
+ None,
+ ));
+ }
+ Err(err) => {
+ last_status = None;
+ last_retry_after = None;
+ last_network = Some(err);
+ }
+ }
+
+ if attempt == self.max_attempts {
+ break;
+ }
+
+ let mut wait = self.backoff(attempt, last_retry_after);
+ if let Some(d) = deadline {
+ let left = d.saturating_duration_since(Instant::now());
+ if left.is_zero() {
+ break;
+ }
+ wait = wait.min(left);
+ }
+ std::thread::sleep(wait);
+ }
+
+ let attempts = self.max_attempts;
+ match (last_status, last_network) {
+ (Some(429), _) => Err(Error::RateLimited {
+ message: format!("rate limit reached at {url} (after {attempts} attempt(s))"),
+ retry_after: last_retry_after,
+ }),
+ (Some(status), _) => Err(Error::Server {
+ message: format!(
+ "the API answered {status} at {url} (after {attempts} attempt(s))"
+ ),
+ status,
+ }),
+ (None, source) => {
+ let detail = source
+ .as_ref()
+ .map(|e| e.to_string())
+ .unwrap_or_else(|| "no attempt was made within the total timeout".to_owned());
+ Err(Error::network(
+ format!("could not reach {url} (after {attempts} attempt(s)): {detail}"),
+ source.map(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>),
+ ))
+ }
+ }
+ }
+
+ /// How long to wait before the next attempt.
+ ///
+ /// The server's `Retry-After` wins over the local calculation when present:
+ /// it knows when it will be back, we are guessing. It still goes through
+ /// the `backoff_max` cap. Otherwise the wait is drawn from `[0, ceiling]`
+ /// rather than being the ceiling.
+ fn backoff(&self, attempt: u32, retry_after: Option<Duration>) -> Duration {
+ if let Some(asked) = retry_after {
+ return asked.min(self.backoff_max);
+ }
+ let ceiling = self
+ .backoff_base
+ .saturating_mul(1u32 << (attempt - 1).min(31))
+ .min(self.backoff_max);
+ jitter(ceiling)
+ }
+}
+
+/// Decode a JSON object body, or fail with [`Error::InvalidResponse`].
+fn json_object(body: &[u8]) -> Result<Value> {
+ let value: Value = serde_json::from_slice(body).map_err(|err| {
+ Error::invalid_response(format!(
+ "response is not valid JSON — something between you and the API is \
+ answering instead of it: {}: {err}",
+ preview(body)
+ ))
+ })?;
+
+ if value.is_object() {
+ Ok(value)
+ } else {
+ Err(Error::invalid_response(format!(
+ "expected a JSON object, got {}",
+ preview(body)
+ )))
+ }
+}
+
+/// Pull `mensagem` out of an error body, falling back to a default.
+fn error_message(body: &[u8], fallback: &str) -> String {
+ serde_json::from_slice::<Value>(body)
+ .ok()
+ .as_ref()
+ .and_then(|v| v.get("mensagem"))
+ .and_then(Value::as_str)
+ .filter(|m| !m.is_empty())
+ .map(str::to_owned)
+ .unwrap_or_else(|| fallback.to_owned())
+}
+
+fn preview(body: &[u8]) -> String {
+ let text = String::from_utf8_lossy(body);
+ let cut = text
+ .char_indices()
+ .nth(120)
+ .map(|(i, _)| i)
+ .unwrap_or(text.len());
+ format!("{:?}", &text[..cut])
+}
diff --git a/src/csv.rs b/src/csv.rs
new file mode 100644
index 0000000..e434989
--- /dev/null
+++ b/src/csv.rs
@@ -0,0 +1,99 @@
+//! A small RFC 4180 reader for the `/csv` batch endpoint.
+//!
+//! Values may contain commas and are then double-quoted with `""` escaping,
+//! which is why the batch cannot be split on `,`. The reader is deliberately
+//! lenient: blank lines are skipped and a stray quote inside an unquoted field
+//! is kept as data, because a malformed byte in one row must not cost the whole
+//! batch.
+
+/// Split CSV text into records, honouring quoting, `""` escapes, embedded
+/// commas and newlines, CRLF and LF line endings, and a leading BOM.
+pub(crate) fn parse(input: &str) -> Vec<Vec<String>> {
+ let input = input.strip_prefix('\u{feff}').unwrap_or(input);
+
+ let mut records: Vec<Vec<String>> = Vec::new();
+ let mut record: Vec<String> = Vec::new();
+ let mut field = String::new();
+ let mut quoted = false;
+ let mut chars = input.chars().peekable();
+
+ while let Some(c) = chars.next() {
+ if quoted {
+ if c == '"' {
+ if chars.peek() == Some(&'"') {
+ chars.next();
+ field.push('"');
+ } else {
+ quoted = false;
+ }
+ } else {
+ field.push(c);
+ }
+ continue;
+ }
+
+ match c {
+ '"' if field.is_empty() => quoted = true,
+ ',' => record.push(std::mem::take(&mut field)),
+ '\n' | '\r' => {
+ if c == '\r' && chars.peek() == Some(&'\n') {
+ chars.next();
+ }
+ record.push(std::mem::take(&mut field));
+ records.push(std::mem::take(&mut record));
+ }
+ _ => field.push(c),
+ }
+ }
+
+ if !field.is_empty() || !record.is_empty() {
+ record.push(field);
+ records.push(record);
+ }
+
+ records.retain(|r| r.iter().any(|f| !f.is_empty()));
+ records
+}
+
+#[cfg(test)]
+mod tests {
+ use super::parse;
+
+ #[test]
+ fn splits_plain_rows() {
+ let records = parse("a,b,c\n1,2,3\n");
+ assert_eq!(records, vec![vec!["a", "b", "c"], vec!["1", "2", "3"]]);
+ }
+
+ #[test]
+ fn keeps_empty_fields() {
+ let records = parse("99999999,,,,,,,,,,,,\n");
+ assert_eq!(records.len(), 1);
+ assert_eq!(records[0].len(), 13);
+ }
+
+ #[test]
+ fn reads_quoted_field_with_comma() {
+ let records = parse("cep,logradouro\n01001000,\"Rua Um, Dois\"\n");
+ assert_eq!(records[1][1], "Rua Um, Dois");
+ }
+
+ #[test]
+ fn unescapes_doubled_quotes() {
+ let records = parse("a\n\"say \"\"hi\"\"\"\n");
+ assert_eq!(records[1][0], "say \"hi\"");
+ }
+
+ #[test]
+ fn accepts_embedded_newline_and_crlf() {
+ let records = parse("a,b\r\n\"line\nbreak\",2\r\n");
+ assert_eq!(records[1][0], "line\nbreak");
+ assert_eq!(records[1][1], "2");
+ }
+
+ #[test]
+ fn ignores_bom_and_blank_lines() {
+ let records = parse("\u{feff}a,b\n\n1,2");
+ assert_eq!(records, vec![vec!["a", "b"], vec!["1", "2"]]);
+ }
+}
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..40a3049
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,150 @@
+//! Error type.
+//!
+//! Everything the library can fail with is one [`Error`], so a caller who only
+//! cares that *something* went wrong matches one type and is done.
+//!
+//! Below that, the split is by **the decision the caller has to make**, not by
+//! the HTTP status that caused it:
+//!
+//! - [`Error::InvalidCep`] and [`Error::NotFound`] are problems with the
+//! *request*. Retrying is pointless — the same CEP will fail again.
+//! - [`Error::Network`], [`Error::Server`] and [`Error::RateLimited`] are
+//! problems with *transport*. Retrying may help, and the client already
+//! retried on its own before returning any of them.
+//!
+//! That split is why `NotFound` is not "the HTTP 404 variant": the client also
+//! returns `InvalidCep` without making any request at all, when the CEP is
+//! malformed before it ever leaves the machine.
+
+use std::error::Error as StdError;
+use std::fmt;
+use std::time::Duration;
+
+/// The result of every fallible operation in this crate.
+pub type Result<T> = std::result::Result<T, Error>;
+
+/// Everything that can go wrong when talking to the API.
+///
+/// Use [`Error::is_retriable`] when the choice is simply "give up or try again
+/// later"; match on the variants when the caller needs to tell a bad CEP from a
+/// bad network.
+#[derive(Debug)]
+#[non_exhaustive]
+pub enum Error {
+ /// The CEP is not 8 digits.
+ ///
+ /// Usually returned **without** a request ever being made — the client
+ /// checks the format before going to the network. It is also what a 400
+ /// from the API becomes, in which case the message is the API's own.
+ InvalidCep {
+ /// Human-readable explanation, including the offending value.
+ message: String,
+ },
+
+ /// The CEP is well-formed but does not exist in the DNE database.
+ NotFound {
+ /// The CEP that was looked up, unmasked.
+ cep: String,
+ },
+
+ /// The API (or the edge in front of it) answered 429 on every attempt.
+ RateLimited {
+ /// Human-readable explanation.
+ message: String,
+ /// What the server asked for via `Retry-After`, or `None` if it did not
+ /// say. The client already honoured it across the attempts it made; it
+ /// is exposed here for callers who want to reschedule the work.
+ retry_after: Option<Duration>,
+ },
+
+ /// The API answered 5xx on every attempt.
+ Server {
+ /// Human-readable explanation.
+ message: String,
+ /// The HTTP status seen on the last attempt.
+ status: u16,
+ },
+
+ /// The request never completed: DNS, refused connection, timeout, TLS.
+ ///
+ /// Returned only after every configured attempt has failed.
+ Network {
+ /// Human-readable explanation.
+ message: String,
+ /// The underlying transport error, when there is one.
+ source: Option<Box<dyn StdError + Send + Sync>>,
+ },
+
+ /// A success status came back with a body that cannot be parsed.
+ ///
+ /// In practice this means something between you and the API is answering
+ /// instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error
+ /// page. Useful as a diagnosis: the problem is not in your code.
+ InvalidResponse {
+ /// Human-readable explanation.
+ message: String,
+ },
+}
+
+impl Error {
+ /// Whether trying the same call again later could plausibly succeed.
+ ///
+ /// `true` for [`Error::RateLimited`], [`Error::Server`] and
+ /// [`Error::Network`]; `false` for a bad or unknown CEP, which fails the
+ /// same way forever.
+ ///
+ /// The client has already retried internally by the time any of these are
+ /// returned, so "later" means later — not immediately.
+ pub fn is_retriable(&self) -> bool {
+ matches!(
+ self,
+ Error::RateLimited { .. } | Error::Server { .. } | Error::Network { .. }
+ )
+ }
+
+ pub(crate) fn invalid_cep(message: impl Into<String>) -> Self {
+ Error::InvalidCep {
+ message: message.into(),
+ }
+ }
+
+ pub(crate) fn invalid_response(message: impl Into<String>) -> Self {
+ Error::InvalidResponse {
+ message: message.into(),
+ }
+ }
+
+ pub(crate) fn network(
+ message: impl Into<String>,
+ source: Option<Box<dyn StdError + Send + Sync>>,
+ ) -> Self {
+ Error::Network {
+ message: message.into(),
+ source,
+ }
+ }
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Error::InvalidCep { message } => f.write_str(message),
+ Error::NotFound { cep } => {
+ write!(f, "CEP {cep} does not exist in the DNE database")
+ }
+ Error::RateLimited { message, .. } => f.write_str(message),
+ Error::Server { message, .. } => f.write_str(message),
+ Error::Network { message, .. } => f.write_str(message),
+ Error::InvalidResponse { message } => f.write_str(message),
+ }
+ }
+}
+
+impl StdError for Error {
+ fn source(&self) -> Option<&(dyn StdError + 'static)> {
+ match self {
+ Error::Network { source, .. } => source.as_ref().map(|e| e.as_ref() as &dyn StdError),
+ _ => None,
+ }
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..84262d7
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,100 @@
+//! Rust client for the public CEP API at [cep.is](https://cep.is). Official
+//! Correios data (DNE), refreshed every two weeks.
+//!
+//! Quick use — for a script, the free functions are enough:
+//!
+//! ```no_run
+//! let address = cepis::lookup("01001-000")?;
+//! println!("{} {}", address.localidade, address.uf); // São Paulo SP
+//! # Ok::<_, cepis::Error>(())
+//! ```
+//!
+//! In an application — build a [`Client`] once and reuse it:
+//!
+//! ```no_run
+//! use std::time::Duration;
+//!
+//! let client = cepis::Client::builder()
+//! .timeout(Duration::from_secs(5))
+//! .total_timeout(Some(Duration::from_secs(15)))
+//! .build();
+//!
+//! let address = client.lookup("01001000")?;
+//! # Ok::<_, cepis::Error>(())
+//! ```
+//!
+//! Pointing at a local instance (the API repo brings one up with `just up`):
+//!
+//! ```no_run
+//! let client = cepis::Client::builder()
+//! .base_url("http://localhost:8080")
+//! .build();
+//! # let _ = client;
+//! ```
+//!
+//! Errors are one type, split by decision — whether retrying helps or not:
+//!
+//! ```no_run
+//! # let cep_from_form = "01001000";
+//! match cepis::lookup(cep_from_form) {
+//! Ok(address) => println!("{}", address.localidade),
+//! Err(cepis::Error::NotFound { .. }) => {} // ask the user for another CEP
+//! Err(err) if err.is_retriable() => {} // network, server, rate limit
+//! Err(err) => eprintln!("{err}"),
+//! }
+//! ```
+//!
+//! Note on field names: the API surface is English, but the **data fields keep
+//! the API's own Portuguese names** (`logradouro`, `bairro`, `localidade`).
+//! What you read in the JSON is what you access on the value.
+//!
+//! The free functions share one default client, built on first use. It keeps no
+//! state between calls and is safe to share across threads. Anyone needing
+//! different settings should build their own [`Client`].
+
+#![forbid(unsafe_code)]
+#![warn(missing_docs)]
+
+mod client;
+mod csv;
+mod error;
+mod model;
+mod retry;
+
+pub use crate::client::{
+ Client, ClientBuilder, DEFAULT_URL, MAX_BATCH, RETRIABLE_STATUS, normalize_cep,
+};
+pub use crate::error::{Error, Result};
+pub use crate::model::{Address, Geo, Health};
+
+/// The version of this library, as sent in the `User-Agent`.
+pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+
+use std::sync::OnceLock;
+
+fn default_client() -> &'static Client {
+ static DEFAULT: OnceLock<Client> = OnceLock::new();
+ DEFAULT.get_or_init(Client::new)
+}
+
+/// Look up a CEP using the default configuration.
+///
+/// Shorthand for `Client::new().lookup(cep)`; see [`Client::lookup`].
+pub fn lookup(cep: &str) -> Result<Address> {
+ default_client().lookup(cep)
+}
+
+/// Look up many CEPs in one batch, using the default configuration.
+///
+/// Shorthand for [`Client::lookup_many`] — read that one, especially the
+/// warning that batches carry no coordinate.
+pub fn lookup_many(ceps: &[impl AsRef<str>]) -> Result<Vec<Option<Address>>> {
+ default_client().lookup_many(ceps)
+}
+
+/// Query the API's health using the default configuration.
+///
+/// Shorthand for [`Client::health`].
+pub fn health() -> Result<Health> {
+ default_client().health()
+}
diff --git a/src/model.rs b/src/model.rs
new file mode 100644
index 0000000..59cacbe
--- /dev/null
+++ b/src/model.rs
@@ -0,0 +1,227 @@
+//! The data the API returns, as types.
+//!
+//! **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`,
+//! `localidade`. Everything else in this crate is English; the fields are the
+//! one deliberate exception.
+//!
+//! The reason is fidelity: what you read in the JSON at <https://cep.is/01001000>
+//! is exactly what you access on the value. Translating to `street`/`city`
+//! would mean maintaining a field-by-field mapping and forcing anyone reading
+//! the API docs alongside this crate to translate back in their head. The
+//! values are Portuguese either way — they come from the Correios database.
+
+use serde_json::Value;
+
+/// The CEP's coordinate, when the database has one.
+///
+/// The point is the **median** of the coordinates of that CEP's addresses, not
+/// the mean: one badly geocoded address 40 km away shifts a mean and does not
+/// move a median.
+///
+/// Always check [`precisao`](Geo::precisao) before using `lat`/`lon` for
+/// distance:
+///
+/// - `"logradouro"`: the point is a stretch of street. `raio_m` lands around
+/// 100–150 m. Good for maps and for distance.
+/// - `"localidade"`: the CEP *is* the whole municipality (a city-wide general
+/// CEP), and the point is its centroid. `raio_m` runs from 7 km to 40 km.
+/// Computing shipping from this is wrong by tens of kilometres.
+///
+/// `raio_m` is the radius containing 90% of that CEP's addresses. It exists
+/// precisely so that error cannot pass unnoticed.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Geo {
+ /// Latitude in decimal degrees, WGS 84.
+ pub lat: f64,
+ /// Longitude in decimal degrees, WGS 84.
+ pub lon: f64,
+ /// Radius, in metres, containing 90% of the CEP's addresses.
+ pub raio_m: i64,
+ /// `"logradouro"` (a street) or `"localidade"` (a municipality centroid).
+ pub precisao: String,
+}
+
+impl Geo {
+ /// `true` when the coordinate points at a street, not a municipality.
+ ///
+ /// Shorthand for `precisao == "logradouro"`, which is the check to make
+ /// before using the point to measure distance.
+ pub fn is_precise(&self) -> bool {
+ self.precisao == "logradouro"
+ }
+
+ fn from_json(value: &Value) -> Option<Geo> {
+ let object = value.as_object()?;
+ Some(Geo {
+ lat: number(object.get("lat")?)?,
+ lon: number(object.get("lon")?)?,
+ raio_m: number(object.get("raio_m")?)? as i64,
+ precisao: match object.get("precisao")? {
+ Value::String(s) => s.clone(),
+ _ => return None,
+ },
+ })
+ }
+}
+
+/// A CEP and everything the database knows about it.
+///
+/// Fields the database does not have for that CEP come back as empty strings,
+/// never absent — that is what the API returns, and changing it here would
+/// create a difference between reading the JSON and reading this crate.
+///
+/// [`geo`](Address::geo) is the exception: it is `None` when there is no
+/// coordinate. There the difference is real, because the API **omits** the
+/// whole field in that case (about 43% of CEPs) rather than sending null.
+///
+/// [`raw`](Address::raw) holds the response exactly as it arrived,
+/// uninterpreted, so a field the API grows before this crate is updated is not
+/// lost.
+#[derive(Debug, Clone, PartialEq)]
+#[non_exhaustive]
+pub struct Address {
+ /// The CEP, always unmasked: `"01001000"`.
+ pub cep: String,
+ /// Street, avenue, square — the public way.
+ pub logradouro: String,
+ /// Range or side of the street the CEP covers, when it covers only part.
+ pub complemento: String,
+ /// Name of the large user or building the CEP belongs to, when it does.
+ pub unidade: String,
+ /// Neighbourhood.
+ pub bairro: String,
+ /// Municipality.
+ pub localidade: String,
+ /// Federative unit, two letters: `"SP"`.
+ pub uf: String,
+ /// Federative unit spelled out: `"São Paulo"`.
+ pub estado: String,
+ /// Region: `"Sudeste"`, `"Nordeste"`, and so on.
+ pub regiao: String,
+ /// IBGE municipality code — the key to join with public datasets.
+ pub ibge: String,
+ /// GIA code, São Paulo state tax administration only.
+ pub gia: String,
+ /// Area code for telephony: `"11"`.
+ pub ddd: String,
+ /// SIAFI municipality code, federal administration.
+ pub siafi: String,
+ /// The coordinate, or `None` when the database has none for this CEP.
+ ///
+ /// Always `None` for an address that came from
+ /// [`lookup_many`](crate::Client::lookup_many) — batches carry no
+ /// coordinate at all.
+ pub geo: Option<Geo>,
+ /// The response exactly as it arrived, so a new API field is never lost.
+ pub raw: Value,
+}
+
+impl Address {
+ /// The CEP with its mask: `01001-000`.
+ ///
+ /// The native contract always returns it unmasked; formatting is
+ /// presentation, and it lives here so it does not become a repeated
+ /// `format!` in every project that uses this.
+ pub fn formatted_cep(&self) -> String {
+ if self.cep.len() == 8 {
+ format!("{}-{}", &self.cep[..5], &self.cep[5..])
+ } else {
+ self.cep.clone()
+ }
+ }
+
+ /// Build from the JSON object the API returned.
+ ///
+ /// Unknown fields are ignored during construction but preserved in
+ /// [`raw`](Address::raw). A new field in the API therefore cannot break
+ /// this crate.
+ ///
+ /// A malformed `geo` block yields `geo: None` rather than failing the whole
+ /// value: it is not worth losing the address over, since the rest of the
+ /// response is still useful.
+ pub fn from_json(value: Value) -> Address {
+ let geo = value.get("geo").and_then(Geo::from_json);
+ Address {
+ cep: text(&value, "cep"),
+ logradouro: text(&value, "logradouro"),
+ complemento: text(&value, "complemento"),
+ unidade: text(&value, "unidade"),
+ bairro: text(&value, "bairro"),
+ localidade: text(&value, "localidade"),
+ uf: text(&value, "uf"),
+ estado: text(&value, "estado"),
+ regiao: text(&value, "regiao"),
+ ibge: text(&value, "ibge"),
+ gia: text(&value, "gia"),
+ ddd: text(&value, "ddd"),
+ siafi: text(&value, "siafi"),
+ geo,
+ raw: value,
+ }
+ }
+}
+
+/// The response of `GET /health`.
+///
+/// Good for two things: checking that the API answers, and knowing **which
+/// version of the database** is live. The DNE database changes every two weeks;
+/// [`dne`](Health::dne) identifies it.
+#[derive(Debug, Clone, PartialEq)]
+#[non_exhaustive]
+pub struct Health {
+ /// `"ok"` when the API considers itself healthy.
+ pub status: String,
+ /// Identifier of the fortnightly DNE database currently loaded.
+ pub dne: String,
+ /// How many CEPs the database holds.
+ pub ceps: u64,
+ /// How many of those carry a coordinate.
+ pub geo: u64,
+ /// When the database was generated, RFC 3339 in UTC.
+ pub base_gerada_em: String,
+ /// The response exactly as it arrived.
+ pub raw: Value,
+}
+
+impl Health {
+ /// `true` when the API reports itself healthy.
+ pub fn ok(&self) -> bool {
+ self.status == "ok"
+ }
+
+ /// Build from the JSON object the API returned.
+ pub fn from_json(value: Value) -> Health {
+ Health {
+ status: text(&value, "status"),
+ dne: text(&value, "dne"),
+ ceps: count(&value, "ceps"),
+ geo: count(&value, "geo"),
+ base_gerada_em: text(&value, "base_gerada_em"),
+ raw: value,
+ }
+ }
+}
+
+fn text(value: &Value, key: &str) -> String {
+ match value.get(key) {
+ Some(Value::String(s)) => s.clone(),
+ Some(Value::Null) | None => String::new(),
+ Some(other) => other.to_string(),
+ }
+}
+
+fn count(value: &Value, key: &str) -> u64 {
+ match value.get(key) {
+ Some(Value::Number(n)) => n.as_u64().unwrap_or(0),
+ Some(Value::String(s)) => s.parse().unwrap_or(0),
+ _ => 0,
+ }
+}
+
+fn number(value: &Value) -> Option<f64> {
+ match value {
+ Value::Number(n) => n.as_f64(),
+ Value::String(s) => s.parse().ok(),
+ _ => None,
+ }
+}
diff --git a/src/retry.rs b/src/retry.rs
new file mode 100644
index 0000000..3e69ff0
--- /dev/null
+++ b/src/retry.rs
@@ -0,0 +1,143 @@
+//! Backoff arithmetic and `Retry-After` parsing.
+
+use std::collections::hash_map::RandomState;
+use std::hash::{BuildHasher, Hasher};
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+/// A random `Duration` in `[0, ceiling]` — *full jitter*.
+///
+/// The randomness is not decoration: without it, N clients that fail together
+/// come back together and reproduce the spike that took the service down.
+///
+/// The source is the standard library's hash seed mixed with the current time,
+/// which is plenty for spreading retries and is not suitable for anything
+/// cryptographic.
+pub(crate) fn jitter(ceiling: Duration) -> Duration {
+ let mut hasher = RandomState::new().build_hasher();
+ hasher.write_u128(
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos(),
+ );
+ let fraction = (hasher.finish() >> 11) as f64 / (1u64 << 53) as f64;
+ ceiling.mul_f64(fraction)
+}
+
+/// Parse the `Retry-After` header in both RFC 9110 forms.
+///
+/// It may arrive as a number of seconds (`"120"`) or as an HTTP date
+/// (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Handling only the first is the common
+/// bug, and it makes a client ignore exactly the wait request that matters
+/// most. A date already in the past yields `Duration::ZERO`; anything
+/// unparseable yields `None`.
+pub(crate) fn parse_retry_after(value: &str) -> Option<Duration> {
+ let value = value.trim();
+ if value.is_empty() {
+ return None;
+ }
+
+ if let Ok(seconds) = value.parse::<f64>() {
+ if seconds.is_finite() {
+ return Some(Duration::try_from_secs_f64(seconds.max(0.0)).unwrap_or(Duration::ZERO));
+ }
+ }
+
+ let target = http_date_to_unix(value)?;
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64;
+ Some(Duration::from_secs((target - now).max(0) as u64))
+}
+
+/// Seconds since the Unix epoch for an IMF-fixdate, the form HTTP servers must
+/// send: `Wed, 21 Oct 2026 07:28:00 GMT`. The two obsolete formats RFC 9110
+/// still mentions are not accepted.
+fn http_date_to_unix(value: &str) -> Option<i64> {
+ let rest = value.split_once(", ").map(|(_, r)| r).unwrap_or(value);
+ let mut parts = rest.split_whitespace();
+
+ let day: i64 = parts.next()?.parse().ok()?;
+ let month = month_number(parts.next()?)?;
+ let year: i64 = parts.next()?.parse().ok()?;
+
+ let mut clock = parts.next()?.split(':');
+ let hour: i64 = clock.next()?.parse().ok()?;
+ let minute: i64 = clock.next()?.parse().ok()?;
+ let second: i64 = clock.next()?.parse().ok()?;
+
+ if !(1..=31).contains(&day) || hour > 23 || minute > 59 || second > 60 {
+ return None;
+ }
+
+ Some(days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second)
+}
+
+fn month_number(name: &str) -> Option<i64> {
+ const MONTHS: [&str; 12] = [
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+ ];
+ MONTHS.iter().position(|m| *m == name).map(|i| i as i64 + 1)
+}
+
+/// Days between 1970-01-01 and the given proleptic Gregorian date.
+fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
+ let year = if month <= 2 { year - 1 } else { year };
+ let era = if year >= 0 { year } else { year - 399 } / 400;
+ let year_of_era = year - era * 400;
+ let month_shifted = (month + 9) % 12;
+ let day_of_year = (153 * month_shifted + 2) / 5 + day - 1;
+ let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
+ era * 146_097 + day_of_era - 719_468
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{days_from_civil, http_date_to_unix, jitter, parse_retry_after};
+ use std::time::Duration;
+
+ #[test]
+ fn reads_plain_seconds() {
+ assert_eq!(parse_retry_after(" 7 "), Some(Duration::from_secs(7)));
+ }
+
+ #[test]
+ fn negative_seconds_become_zero() {
+ assert_eq!(parse_retry_after("-5"), Some(Duration::ZERO));
+ }
+
+ #[test]
+ fn reads_an_http_date() {
+ let epoch = http_date_to_unix("Wed, 21 Oct 2026 07:28:00 GMT").unwrap();
+ assert_eq!(epoch, 1_792_567_680);
+ }
+
+ #[test]
+ fn a_date_in_the_past_is_zero() {
+ assert_eq!(
+ parse_retry_after("Thu, 01 Jan 1970 00:00:00 GMT"),
+ Some(Duration::ZERO)
+ );
+ }
+
+ #[test]
+ fn rejects_nonsense() {
+ assert_eq!(parse_retry_after("soon"), None);
+ assert_eq!(parse_retry_after(""), None);
+ }
+
+ #[test]
+ fn civil_days_match_known_dates() {
+ assert_eq!(days_from_civil(1970, 1, 1), 0);
+ assert_eq!(days_from_civil(2000, 3, 1), 11_017);
+ }
+
+ #[test]
+ fn jitter_stays_within_the_ceiling() {
+ let ceiling = Duration::from_millis(50);
+ for _ in 0..100 {
+ assert!(jitter(ceiling) <= ceiling);
+ }
+ }
+}
diff --git a/tests/api.rs b/tests/api.rs
new file mode 100644
index 0000000..c794687
--- /dev/null
+++ b/tests/api.rs
@@ -0,0 +1,643 @@
+//! Integration tests.
+//!
+//! These bring up a real HTTP server on loopback and point the client at it via
+//! `base_url`. No test touches the network and no test fakes the HTTP layer:
+//! the code path exercised is the one that runs in production, request
+//! construction and error body reading included.
+//!
+//! It doubles as the test that `base_url` works — if it did not, none of this
+//! would run.
+//!
+//! The fixture field names stay Portuguese because they mirror the API's JSON.
+
+use std::collections::HashMap;
+use std::io::{BufRead, BufReader, Write};
+use std::net::{SocketAddr, TcpListener, TcpStream};
+use std::sync::{Arc, Mutex};
+use std::time::{Duration, Instant};
+
+use cepis::{Client, Error, normalize_cep};
+
+const SE_ADDRESS: &str = r#"{
+ "cep": "01001000", "logradouro": "Praça da Sé", "complemento": "lado ímpar",
+ "unidade": "", "bairro": "Sé", "localidade": "São Paulo", "uf": "SP",
+ "estado": "São Paulo", "regiao": "Sudeste", "ibge": "3550308", "gia": "1004",
+ "ddd": "11", "siafi": "7107"
+}"#;
+
+const CSV_HEADER: &str =
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi";
+
+/// One canned response: status, body and extra headers.
+#[derive(Clone)]
+struct Reply {
+ status: u16,
+ body: String,
+ headers: Vec<(String, String)>,
+}
+
+impl Reply {
+ fn new(status: u16, body: &str) -> Reply {
+ Reply {
+ status,
+ body: body.to_owned(),
+ headers: Vec::new(),
+ }
+ }
+
+ fn header(mut self, name: &str, value: &str) -> Reply {
+ self.headers.push((name.to_owned(), value.to_owned()));
+ self
+ }
+}
+
+#[derive(Default)]
+struct State {
+ routes: HashMap<String, Vec<Reply>>,
+ calls: Vec<String>,
+}
+
+/// A throwaway HTTP server on a random loopback port.
+///
+/// Routes are matched by prefix, so `/csv` catches `/csv?ceps=...`. A route may
+/// hold several replies: each request consumes the next one, which is how
+/// retrying is tested, and the last one repeats once the list runs out.
+///
+/// The accept loop runs on a detached thread that ends with the test process;
+/// nothing here outlives `cargo test`.
+struct Server {
+ addr: SocketAddr,
+ state: Arc<Mutex<State>>,
+}
+
+impl Server {
+ fn start() -> Server {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
+ let addr = listener.local_addr().expect("local addr");
+ let state = Arc::new(Mutex::new(State::default()));
+ let shared = Arc::clone(&state);
+
+ std::thread::spawn(move || {
+ for stream in listener.incoming() {
+ match stream {
+ Ok(stream) => handle(stream, &shared),
+ Err(_) => break,
+ }
+ }
+ });
+
+ Server { addr, state }
+ }
+
+ fn url(&self) -> String {
+ format!("http://{}", self.addr)
+ }
+
+ fn client(&self) -> Client {
+ Client::builder()
+ .base_url(self.url())
+ .timeout(Duration::from_secs(5))
+ .backoff_base(Duration::from_millis(1))
+ .backoff_max(Duration::from_millis(10))
+ .build()
+ }
+
+ fn respond(&self, path: &str, replies: Vec<Reply>) {
+ self.state
+ .lock()
+ .unwrap()
+ .routes
+ .insert(path.to_owned(), replies);
+ }
+
+ fn calls(&self) -> usize {
+ self.state.lock().unwrap().calls.len()
+ }
+
+ fn count(&self, prefix: &str) -> usize {
+ self.state
+ .lock()
+ .unwrap()
+ .calls
+ .iter()
+ .filter(|c| c.starts_with(prefix))
+ .count()
+ }
+}
+
+fn handle(mut stream: TcpStream, state: &Arc<Mutex<State>>) {
+ let mut reader = BufReader::new(stream.try_clone().expect("clone stream"));
+ let mut request_line = String::new();
+ if reader.read_line(&mut request_line).is_err() || request_line.is_empty() {
+ return;
+ }
+ loop {
+ let mut header = String::new();
+ match reader.read_line(&mut header) {
+ Ok(0) => break,
+ Ok(_) if header == "\r\n" || header == "\n" => break,
+ Ok(_) => continue,
+ Err(_) => return,
+ }
+ }
+
+ let path = request_line
+ .split_whitespace()
+ .nth(1)
+ .unwrap_or("/")
+ .to_owned();
+
+ let reply = {
+ let mut state = state.lock().unwrap();
+ state.calls.push(path.clone());
+ let key = state
+ .routes
+ .keys()
+ .filter(|k| path.starts_with(*k))
+ .max_by_key(|k| k.len())
+ .cloned();
+ match key.and_then(|k| state.routes.get_mut(&k)) {
+ Some(replies) if replies.len() > 1 => replies.remove(0),
+ Some(replies) => replies[0].clone(),
+ None => Reply::new(404, r#"{"erro":"cep_nao_encontrado"}"#),
+ }
+ };
+
+ let body = reply.body.as_bytes();
+ let mut response = format!("HTTP/1.1 {} Status\r\n", reply.status);
+ for (name, value) in &reply.headers {
+ response.push_str(&format!("{name}: {value}\r\n"));
+ }
+ response.push_str(&format!("Content-Length: {}\r\n", body.len()));
+ response.push_str("Connection: close\r\n\r\n");
+
+ let _ = stream.write_all(response.as_bytes());
+ let _ = stream.write_all(body);
+ let _ = stream.flush();
+}
+
+fn csv_body(rows: &[String]) -> String {
+ format!("{}\n{}\n", CSV_HEADER, rows.join("\n"))
+}
+
+fn with_geo(precisao: &str, raio_m: i64) -> String {
+ format!(
+ r#"{{"cep":"01310100","logradouro":"Avenida Paulista","complemento":"","unidade":"","bairro":"Bela Vista","localidade":"São Paulo","uf":"SP","estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004","ddd":"11","siafi":"7107","geo":{{"lat":-23.5,"lon":-46.6,"raio_m":{raio_m},"precisao":"{precisao}"}}}}"#
+ )
+}
+
+#[test]
+fn normalizes_masked_and_unmasked() {
+ for value in ["01001000", "01001-000", " 01001-000 ", "01001.000"] {
+ assert_eq!(normalize_cep(value).unwrap(), "01001000");
+ }
+}
+
+#[test]
+fn rejects_bad_format() {
+ for value in ["", "123", "0100100a", "010010000", "abcdefgh"] {
+ assert!(matches!(
+ normalize_cep(value),
+ Err(Error::InvalidCep { .. })
+ ));
+ }
+}
+
+#[test]
+fn successful_lookup() {
+ let server = Server::start();
+ server.respond("/01001000", vec![Reply::new(200, SE_ADDRESS)]);
+
+ let address = server.client().lookup("01001-000").unwrap();
+
+ assert_eq!(address.localidade, "São Paulo");
+ assert_eq!(address.uf, "SP");
+ assert_eq!(address.logradouro, "Praça da Sé");
+ assert_eq!(address.formatted_cep(), "01001-000");
+ assert!(address.geo.is_none());
+}
+
+#[test]
+fn reads_the_coordinate_when_present() {
+ let server = Server::start();
+ server.respond(
+ "/01310100",
+ vec![Reply::new(200, &with_geo("logradouro", 120))],
+ );
+
+ let address = server.client().lookup("01310100").unwrap();
+ let geo = address.geo.expect("geo present");
+
+ assert!((geo.lat + 23.5).abs() < 1e-9);
+ assert!((geo.lon + 46.6).abs() < 1e-9);
+ assert_eq!(geo.raio_m, 120);
+ assert!(geo.is_precise());
+}
+
+#[test]
+fn municipality_coordinate_is_not_precise() {
+ let server = Server::start();
+ server.respond(
+ "/01310100",
+ vec![Reply::new(200, &with_geo("localidade", 22000))],
+ );
+
+ let geo = server
+ .client()
+ .lookup("01310100")
+ .unwrap()
+ .geo
+ .expect("geo present");
+
+ assert!(!geo.is_precise());
+}
+
+#[test]
+fn malformed_geo_does_not_lose_the_address() {
+ let server = Server::start();
+ let body = SE_ADDRESS.replace(
+ r#""siafi": "7107""#,
+ r#""siafi": "7107", "geo": {"lat": "not a number"}"#,
+ );
+ server.respond("/01001000", vec![Reply::new(200, &body)]);
+
+ let address = server.client().lookup("01001000").unwrap();
+
+ assert!(address.geo.is_none());
+ assert_eq!(address.localidade, "São Paulo");
+}
+
+#[test]
+fn a_new_api_field_does_not_break_the_client() {
+ let server = Server::start();
+ let body = SE_ADDRESS.replace(
+ r#""siafi": "7107""#,
+ r#""siafi": "7107", "campo_do_futuro": "value""#,
+ );
+ server.respond("/01001000", vec![Reply::new(200, &body)]);
+
+ let address = server.client().lookup("01001000").unwrap();
+
+ assert_eq!(address.raw["campo_do_futuro"], "value");
+ assert_eq!(address.localidade, "São Paulo");
+}
+
+#[test]
+fn not_found() {
+ let server = Server::start();
+ server.respond(
+ "/99999999",
+ vec![Reply::new(
+ 404,
+ r#"{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}"#,
+ )],
+ );
+
+ let err = server.client().lookup("99999999").unwrap_err();
+
+ assert!(matches!(&err, Error::NotFound { cep } if cep == "99999999"));
+ assert!(!err.is_retriable());
+}
+
+#[test]
+fn not_found_is_not_retried() {
+ let server = Server::start();
+ server.respond("/99999999", vec![Reply::new(404, r#"{"erro":"x"}"#)]);
+
+ assert!(server.client().lookup("99999999").is_err());
+ assert_eq!(server.count("/99999999"), 1);
+}
+
+#[test]
+fn an_invalid_cep_never_leaves_the_machine() {
+ let server = Server::start();
+
+ let err = server.client().lookup("123").unwrap_err();
+
+ assert!(matches!(err, Error::InvalidCep { .. }));
+ assert_eq!(server.calls(), 0);
+}
+
+#[test]
+fn api_400_becomes_invalid_cep() {
+ let server = Server::start();
+ server.respond(
+ "/00000000",
+ vec![Reply::new(
+ 400,
+ r#"{"erro":"cep_invalido","mensagem":"rejected by the API"}"#,
+ )],
+ );
+
+ let err = server.client().lookup("00000000").unwrap_err();
+
+ assert!(matches!(err, Error::InvalidCep { .. }));
+ assert!(err.to_string().contains("rejected by the API"));
+ assert_eq!(server.count("/00000000"), 1);
+}
+
+#[test]
+fn a_response_that_is_not_json() {
+ let server = Server::start();
+ server.respond(
+ "/01001000",
+ vec![Reply::new(200, "<html>captive portal</html>")],
+ );
+
+ let err = server.client().lookup("01001000").unwrap_err();
+
+ assert!(matches!(err, Error::InvalidResponse { .. }));
+}
+
+#[test]
+fn retries_on_500_then_succeeds() {
+ let server = Server::start();
+ server.respond(
+ "/01001000",
+ vec![
+ Reply::new(500, r#"{"erro":"interno"}"#),
+ Reply::new(200, SE_ADDRESS),
+ ],
+ );
+
+ let address = server.client().lookup("01001000").unwrap();
+
+ assert_eq!(address.localidade, "São Paulo");
+ assert_eq!(server.count("/01001000"), 2);
+}
+
+#[test]
+fn exhausts_the_attempts_then_fails() {
+ let server = Server::start();
+ server.respond("/01001000", vec![Reply::new(503, r#"{"erro":"interno"}"#)]);
+
+ let err = server.client().lookup("01001000").unwrap_err();
+
+ assert!(matches!(err, Error::Server { status: 503, .. }));
+ assert!(err.is_retriable());
+ assert_eq!(server.count("/01001000"), 3);
+}
+
+#[test]
+fn max_attempts_one_disables_retrying() {
+ let server = Server::start();
+ server.respond("/01001000", vec![Reply::new(500, "{}")]);
+
+ let client = Client::builder()
+ .base_url(server.url())
+ .max_attempts(1)
+ .timeout(Duration::from_secs(5))
+ .build();
+
+ assert!(matches!(
+ client.lookup("01001000"),
+ Err(Error::Server { status: 500, .. })
+ ));
+ assert_eq!(server.count("/01001000"), 1);
+}
+
+#[test]
+fn rate_limited_exposes_retry_after() {
+ let server = Server::start();
+ server.respond(
+ "/01001000",
+ vec![Reply::new(429, "{}").header("Retry-After", "7")],
+ );
+
+ let err = server.client().lookup("01001000").unwrap_err();
+
+ match err {
+ Error::RateLimited { retry_after, .. } => {
+ assert_eq!(retry_after, Some(Duration::from_secs(7)));
+ }
+ other => panic!("expected RateLimited, got {other:?}"),
+ }
+}
+
+#[test]
+fn retry_after_honours_the_cap() {
+ let server = Server::start();
+ server.respond(
+ "/01001000",
+ vec![Reply::new(429, "{}").header("Retry-After", "3600")],
+ );
+
+ let started = Instant::now();
+ assert!(server.client().lookup("01001000").is_err());
+
+ assert!(started.elapsed() < Duration::from_secs(5));
+}
+
+#[test]
+fn retry_after_as_an_http_date_is_understood() {
+ let server = Server::start();
+ server.respond(
+ "/01001000",
+ vec![Reply::new(429, "{}").header("Retry-After", "Wed, 21 Oct 2099 07:28:00 GMT")],
+ );
+
+ let started = Instant::now();
+ let err = server.client().lookup("01001000").unwrap_err();
+
+ match err {
+ Error::RateLimited { retry_after, .. } => {
+ assert!(retry_after.expect("parsed") > Duration::from_secs(86_400));
+ }
+ other => panic!("expected RateLimited, got {other:?}"),
+ }
+ assert!(started.elapsed() < Duration::from_secs(5));
+}
+
+#[test]
+fn network_error_when_nothing_listens() {
+ let client = Client::builder()
+ .base_url("http://127.0.0.1:1")
+ .max_attempts(2)
+ .backoff_base(Duration::from_millis(1))
+ .timeout(Duration::from_secs(1))
+ .build();
+
+ let err = client.lookup("01001000").unwrap_err();
+
+ assert!(matches!(err, Error::Network { .. }));
+ assert!(err.is_retriable());
+}
+
+#[test]
+fn total_timeout_bounds_the_whole_call() {
+ let server = Server::start();
+ server.respond("/01001000", vec![Reply::new(500, "{}")]);
+
+ let client = Client::builder()
+ .base_url(server.url())
+ .timeout(Duration::from_secs(5))
+ .max_attempts(50)
+ .backoff_base(Duration::from_millis(50))
+ .backoff_max(Duration::from_millis(50))
+ .total_timeout(Some(Duration::from_millis(300)))
+ .build();
+
+ let started = Instant::now();
+ assert!(client.lookup("01001000").is_err());
+
+ assert!(started.elapsed() < Duration::from_secs(3));
+ assert!(server.count("/01001000") < 50);
+}
+
+#[test]
+fn batch_preserves_order_and_marks_missing() {
+ let server = Server::start();
+ let body = csv_body(&[
+ "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107"
+ .to_owned(),
+ "99999999,,,,,,,,,,,,".to_owned(),
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001"
+ .to_owned(),
+ ]);
+ server.respond("/csv", vec![Reply::new(200, &body)]);
+
+ let result = server
+ .client()
+ .lookup_many(&["01001000", "99999999", "20040020"])
+ .unwrap();
+
+ assert_eq!(result.len(), 3);
+ assert_eq!(result[0].as_ref().unwrap().localidade, "São Paulo");
+ assert!(result[1].is_none());
+ assert_eq!(result[2].as_ref().unwrap().uf, "RJ");
+}
+
+#[test]
+fn batch_carries_no_coordinate() {
+ let server = Server::start();
+ let body = csv_body(&[
+ "01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107".to_owned(),
+ ]);
+ server.respond("/csv", vec![Reply::new(200, &body)]);
+
+ let result = server.client().lookup_many(&["01001000"]).unwrap();
+
+ assert!(result[0].as_ref().unwrap().geo.is_none());
+}
+
+#[test]
+fn batch_parses_a_field_containing_a_comma() {
+ let server = Server::start();
+ let body = csv_body(&[
+ r#"01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1"#.to_owned(),
+ ]);
+ server.respond("/csv", vec![Reply::new(200, &body)]);
+
+ let result = server.client().lookup_many(&["01001000"]).unwrap();
+
+ assert_eq!(result[0].as_ref().unwrap().logradouro, "Rua Um, Dois");
+}
+
+#[test]
+fn an_empty_batch_makes_no_request() {
+ let server = Server::start();
+ let empty: [&str; 0] = [];
+
+ assert!(server.client().lookup_many(&empty).unwrap().is_empty());
+ assert_eq!(server.calls(), 0);
+}
+
+#[test]
+fn batch_chunks_above_the_api_limit() {
+ let server = Server::start();
+ let ceps: Vec<String> = (0..501).map(|i| format!("{i:08}")).collect();
+ let row = |c: &String| format!("{c},,,,,X,SP,São Paulo,Sudeste,1,,11,1");
+ let first: Vec<String> = ceps[..500].iter().map(row).collect();
+ let second: Vec<String> = ceps[500..].iter().map(row).collect();
+
+ server.respond(
+ "/csv",
+ vec![
+ Reply::new(200, &csv_body(&first)),
+ Reply::new(200, &csv_body(&second)),
+ ],
+ );
+
+ let result = server.client().lookup_many(&ceps).unwrap();
+
+ assert_eq!(result.len(), 501);
+ assert_eq!(server.count("/csv"), 2);
+ assert!(result.iter().all(|a| a.is_some()));
+}
+
+#[test]
+fn a_truncated_batch_pads_instead_of_shifting() {
+ let server = Server::start();
+ let body = csv_body(&["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1".to_owned()]);
+ server.respond("/csv", vec![Reply::new(200, &body)]);
+
+ let result = server
+ .client()
+ .lookup_many(&["01001000", "20040020"])
+ .unwrap();
+
+ assert_eq!(result.len(), 2);
+ assert!(result[0].is_some());
+ assert!(result[1].is_none());
+}
+
+#[test]
+fn reads_health() {
+ let server = Server::start();
+ server.respond(
+ "/health",
+ vec![Reply::new(
+ 200,
+ r#"{"status":"ok","dne":"26071","ceps":1596168,"geo":894489,"base_gerada_em":"2026-07-24T03:48:28Z"}"#,
+ )],
+ );
+
+ let health = server.client().health().unwrap();
+
+ assert!(health.ok());
+ assert_eq!(health.dne, "26071");
+ assert_eq!(health.ceps, 1_596_168);
+ assert_eq!(health.geo, 894_489);
+ assert_eq!(health.base_gerada_em, "2026-07-24T03:48:28Z");
+}
+
+#[test]
+fn a_degraded_api_is_not_an_error() {
+ let server = Server::start();
+ server.respond(
+ "/health",
+ vec![Reply::new(
+ 503,
+ r#"{"status":"degradado","dne":"","ceps":0,"geo":0}"#,
+ )],
+ );
+
+ let health = server.client().health().unwrap();
+
+ assert!(!health.ok());
+ assert_eq!(server.count("/health"), 1);
+}
+
+#[test]
+fn a_trailing_slash_is_not_duplicated() {
+ let client = Client::builder().base_url("http://x/").build();
+
+ assert_eq!(client.base_url(), "http://x");
+}
+
+#[test]
+fn the_user_agent_identifies_the_library() {
+ assert!(Client::new().user_agent().starts_with("cepis-rust/"));
+}
+
+#[test]
+#[should_panic(expected = "max_attempts must be >= 1")]
+fn zero_attempts_is_a_configuration_error() {
+ Client::builder().max_attempts(0).build();
+}
+
+#[test]
+#[should_panic(expected = "timeout must be > 0")]
+fn a_zero_timeout_is_a_configuration_error() {
+ Client::builder().timeout(Duration::ZERO).build();
+}