diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:17:46 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:17:46 -0300 |
| commit | 4a8c3b675df4b1a6b29904fc2af60605002a7f9e (patch) | |
| tree | 5c3dcc6594603d242b6a284c1601fd1ffbfcc8f4 | |
| download | rust-f04e6cf2da99b276c9988f1f98f7ca4059191365.tar.gz | |
Initial importv0.1.0
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | Cargo.lock | 386 | ||||
| -rw-r--r-- | Cargo.toml | 25 | ||||
| -rw-r--r-- | LICENSE | 15 | ||||
| -rw-r--r-- | Makefile | 48 | ||||
| -rw-r--r-- | README.md | 201 | ||||
| -rw-r--r-- | src/bin/cepis.rs | 100 | ||||
| -rw-r--r-- | src/client.rs | 559 | ||||
| -rw-r--r-- | src/csv.rs | 99 | ||||
| -rw-r--r-- | src/error.rs | 150 | ||||
| -rw-r--r-- | src/lib.rs | 100 | ||||
| -rw-r--r-- | src/model.rs | 227 | ||||
| -rw-r--r-- | src/retry.rs | 143 | ||||
| -rw-r--r-- | tests/api.rs | 643 |
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" @@ -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(); +} |