summaryrefslogtreecommitdiffstats
path: root/README.md
diff options
context:
space:
mode:
Diffstat (limited to 'README.md')
-rw-r--r--README.md203
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).