diff options
Diffstat (limited to 'README.md')
| -rw-r--r-- | README.md | 203 |
1 files changed, 203 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb79b2a --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# cepis + +Elixir client for the [public CEP API at cep.is](https://cep.is). Official +Correios data (DNE), refreshed every two weeks. + +```elixir +{:ok, address} = Cepis.lookup("01001-000") +{address.logradouro, address.localidade, address.uf} +# {"Praça da Sé", "São Paulo", "SP"} +``` + +## Installation + +```elixir +def deps do + [{:cepis, git: "https://git.cep.is/elixir.git", tag: "v0.1.0"}] +end +``` + +No dependencies. Requires Elixir 1.14 or newer and Erlang/OTP 27 or newer — +HTTP is `:httpc` and JSON is the `:json` module, both from the standard +distribution. + +## Usage + +Every function returns `{:ok, result}` or `{:error, error}`, and every one has +a bang variant that returns the result directly and raises instead. + +### A single lookup + +`lookup/2` accepts the CEP masked or unmasked and returns a `Cepis.Address`: + +```elixir +{:ok, 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) +Cepis.Address.formatted_cep(a) # "01310-100" +``` + +Field names are the API's own, in Portuguese. Fields the database does not have +for that CEP come back as empty strings, never `nil`. + +### Coordinates + +About 57% of CEPs have a coordinate. When they do not, `geo` is `nil`: + +```elixir +{:ok, a} = Cepis.lookup("01310100") + +if a.geo do + a.geo.lat # -23.567202 + a.geo.lon # -46.649659 + a.geo.raio_m # 1084 + a.geo.precisao # "logradouro" +end +``` + +**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 | + +`Cepis.Geo.precise?/1` is the shorthand for `precisao == "logradouro"`: + +```elixir +if a.geo && Cepis.Geo.precise?(a.geo) do + shipping_from(a.geo.lat, a.geo.lon) +else + shipping_from_municipality(a.ibge) +end +``` + +### Many CEPs at once + +Do not loop over `lookup/2`. Use `lookup_many/2`, which resolves up to 500 CEPs +per request: + +```elixir +ceps = ["01001000", "99999999", "20040020"] +{:ok, addresses} = Cepis.lookup_many(ceps) + +for {requested, address} <- Enum.zip(ceps, addresses) do + case address do + nil -> IO.puts("#{requested}: not found") + a -> IO.puts("#{requested}: #{a.localidade}") + end +end +``` + +The returned list has **the same length and the same order** as the input. A +CEP that does not exist, or is malformed, becomes `nil` at its position — it +does not vanish, and it does not fail the call. + +Lists longer than 500 are chunked automatically. + +> **Batches carry no coordinate.** The endpoint behind them is CSV, and the +> CSV has no `geo` column. Every `Cepis.Address` from `lookup_many/2` has +> `geo: nil`, including for CEPs that do have one. Need `geo`? Use `lookup/2`, +> one at a time. + +### API health + +Useful for two things: checking the API answers, and knowing **which version of +the database** is live: + +```elixir +{:ok, h} = Cepis.health() + +Cepis.Health.ok?(h) # 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 a failure — that arrives in `h.status`. + +## Configuration + +The last argument of every call is the configuration: a keyword list, or a +`Cepis.Client` built once with `Cepis.Client.new/1` and reused. It is plain +data with no process behind it, so it is safe to share across processes. + +```elixir +client = + Cepis.Client.new( + base_url: "https://cep.is", # point at another instance + timeout: 10_000, # milliseconds for one attempt + max_attempts: 3, # counting the first; 1 disables retrying + backoff_base: 500, # base of the exponential wait + backoff_max: 8_000, # cap on each wait + total_timeout: nil # cap on the whole call, attempts included + ) + +{:ok, address} = Cepis.lookup("01001000", client) +``` + +All durations are in milliseconds. Retries cover 429, 5xx and network failures, +with exponential backoff and jitter; 400 and 404 are never retried. See +`Cepis.Client` for the details of each option. + +### Pointing at a local instance + +`base_url` is the only option that usually changes between environments. The +API repo brings a local instance up with `just up`: + +```elixir +Cepis.lookup("01001000", base_url: "http://localhost:8080") +``` + +## Errors + +Matching on `{:error, error}` is the one handler that catches everything. Below +that, the split is by **the decision you have to make**, not by the HTTP status +that caused it: + +| error | when | does retrying help? | +| --- | --- | --- | +| `Cepis.InvalidCEPError` | not 8 digits | no | +| `Cepis.NotFoundError` | well-formed, not in the database | no | +| `Cepis.RateLimitError` | 429 | yes, later | +| `Cepis.ServerError` | 5xx | yes, later | +| `Cepis.NetworkError` | DNS, connection, timeout, TLS | yes, later | +| `Cepis.InvalidResponseError` | unintelligible response | probably not | + +```elixir +case Cepis.lookup(cep_from_form) do + {:ok, address} -> address + {:error, %Cepis.NotFoundError{}} -> ask_for_another_cep() + {:error, error} -> reschedule(error) +end +``` + +`Cepis.Error.retriable?/1` answers the same question in code. +`Cepis.RateLimitError` exposes `retry_after` in milliseconds, ready for +`Process.send_after/3`. + +`Cepis.InvalidResponseError` 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 `mix`: + +```sh +make # list the targets +make test # run the ExUnit suite +make lint # mix format --check-formatted, touches nothing +make format # apply mix format +make build # compile with warnings as errors +make check # lint, then test, then build +``` + +## Licence + +ISC. See [LICENSE](LICENSE). |