summaryrefslogtreecommitdiffstats
path: root/README.md
diff options
context:
space:
mode:
Diffstat (limited to 'README.md')
-rw-r--r--README.md253
1 files changed, 253 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ed4988c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,253 @@
+# cepis
+
+Lua client for the [public CEP API at cep.is](https://cep.is). Official
+Correios data (DNE), refreshed every two weeks.
+
+```lua
+local cepis = require("cepis")
+
+local address = cepis.lookup("01001-000")
+print(address.logradouro, address.localidade, address.uf)
+-- Praça da Sé São Paulo SP
+```
+
+## Installation
+
+```sh
+luarocks install cepis
+```
+
+Targets **Lua 5.4**. It also runs unmodified on 5.1, 5.2, 5.3 and LuaJIT —
+nothing here needs anything newer, so the compatibility was free.
+
+> The rockspec points at `git+https://git.cep.is/lua.git` with tag `v0.1.0`.
+> That repository is not published yet, so `luarocks install cepis` will not
+> resolve until it is. Until then, `luarocks make cepis-0.1.0-1.rockspec` from
+> a clone works.
+
+Three rocks are needed, because Lua's standard library ships neither an HTTP
+client nor a JSON parser:
+
+| rock | why |
+| --- | --- |
+| `luasocket` | the HTTP client, plus `gettime`/`sleep` for backoff |
+| `luasec` | TLS, so the default `https://cep.is` works |
+| `dkjson` | JSON, and pure Lua, so no C toolchain is needed to install it |
+
+CSV is parsed in-tree — a real reader, not `split(",")`, but too small to
+justify a fourth dependency.
+
+`luasec` is loaded lazily, only when `base_url` is `https://`. Pointing at a
+local instance over plain HTTP therefore needs just `luasocket` and `dkjson`.
+
+## Usage
+
+### A single lookup
+
+`lookup` accepts the CEP masked or unmasked:
+
+```lua
+local address, err = cepis.lookup("01310-100")
+
+address.logradouro -- "Avenida Paulista"
+address.localidade -- "São Paulo"
+address.uf -- "SP"
+address.ibge -- "3550308"
+address.cep -- "01310100" (always unmasked)
+address:formatted_cep() -- "01310-100"
+```
+
+Fields the database does not have for that CEP come back as empty strings,
+never `nil`. `address.raw` holds the decoded response untouched, including a
+field the API grew after this release.
+
+Expected failures are returned, never thrown:
+
+```lua
+local address, err = cepis.lookup(cep_from_form)
+if not address then
+ print(err.kind, err.message)
+end
+```
+
+### Coordinates
+
+About 57% of CEPs have a coordinate. When they do not, `geo` is `nil`:
+
+```lua
+if address.geo then
+ print(address.geo.lat, address.geo.lon) -- -23.567202 -46.649659
+ print(address.geo.raio_m) -- 1084
+ print(address.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 |
+
+`address.geo:is_precise()` is the shorthand for `precisao == "logradouro"`:
+
+```lua
+local cost
+if address.geo and address.geo:is_precise() then
+ cost = shipping_from(address.geo.lat, address.geo.lon)
+else
+ cost = shipping_from_municipality(address.ibge)
+end
+```
+
+### Many CEPs at once
+
+Do not loop over `lookup`. Use `lookup_many`, which resolves up to 500 CEPs per
+request:
+
+```lua
+local ceps = { "01001000", "99999999", "20040020" }
+local result, err = cepis.lookup_many(ceps)
+
+for index, address in ipairs(result) do
+ if address then
+ print(ceps[index], address.localidade)
+ else
+ print(ceps[index], "not found")
+ end
+end
+```
+
+The result has **the same length and the same order** as the input.
+
+**A CEP that does not exist, or is malformed, is `false` at its position — not
+`nil`.** That is not cosmetic. A `nil` inside a Lua sequence truncates it at
+the first hole, so `#result` and `ipairs` would stop at the first missing CEP
+and silently drop every address after it. `false` keeps the list dense, and
+`if address then` still reads as the natural test.
+
+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 address from `lookup_many` has `geo == nil`,
+> 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:
+
+```lua
+local health, err = cepis.health()
+
+health:ok() -- true
+health.dne -- "26071" identifier of the fortnightly database
+health.ceps -- 1596168
+health.geo -- 894489
+health.base_gerada_em -- "2026-07-24T03:48:28Z"
+```
+
+An API reporting itself degraded is not a failure — that arrives in
+`health.status`.
+
+## Configuration
+
+For anything beyond the defaults, build a client and reuse it. It keeps no
+state between calls:
+
+```lua
+local client = cepis.new({
+ base_url = "https://cep.is", -- point at another instance
+ timeout = 10, -- seconds, per socket operation
+ max_attempts = 3, -- counting the first; 1 disables retrying
+ backoff_base = 0.5,
+ backoff_max = 8,
+ total_timeout = 15, -- cap on the whole call
+ user_agent = "myapp/1.0",
+})
+
+local address, err = client:lookup("01001000")
+local result = client:lookup_many({ "01001000", "20040020" })
+local health = client:health()
+```
+
+`timeout` bounds a single socket operation — the connect, and each read — not
+the whole call. A call may make several attempts with waits between them; the
+cap on all of that is `total_timeout`.
+
+Retries cover 429, 5xx and network failures, with exponential backoff and full
+jitter; 400 and 404 are never retried.
+
+The module-level functions (`cepis.lookup`, etc.) share a default client,
+reachable as `cepis.default()`. It applies process-wide, other libraries
+included. 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`:
+
+```lua
+local client = cepis.new({ base_url = "http://localhost:8080" })
+```
+
+## Errors
+
+Every expected failure is returned as `nil, err`. `error()` is raised only for
+programmer mistakes — a bad option table, a wrong argument type.
+
+An error is a table carrying `kind`, `message`, and whichever of `cep`,
+`status` and `retry_after` apply. `tostring(err)` yields the message. The kinds
+split by **the decision you have to make**, not by the HTTP status that caused
+it:
+
+| `err.kind` | when | does retrying help? |
+| --- | --- | --- |
+| `cepis.INVALID_CEP` | not 8 digits | no |
+| `cepis.NOT_FOUND` | well-formed, not in the database | no |
+| `cepis.RATE_LIMITED` | 429; see `err.retry_after` | yes, later |
+| `cepis.SERVER_ERROR` | 5xx; see `err.status` | yes, later |
+| `cepis.NETWORK_ERROR` | DNS, connection, timeout, TLS | yes, later |
+| `cepis.INVALID_RESPONSE` | unintelligible response | probably not |
+
+```lua
+local address, err = cepis.lookup(cep_from_form)
+if not address then
+ if err.kind == cepis.NOT_FOUND then
+ -- ask the user for another CEP
+ elseif err:is_retriable() then
+ -- transient failure: reschedule
+ retry_in(err.retry_after or 60)
+ end
+end
+```
+
+The kinds are plain strings, so `err.kind == "not_found"` works too.
+`cepis.is_error(value)` tells one of this library's errors apart from anything
+else a `nil, err` boundary might hand you.
+
+`INVALID_RESPONSE` 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`:
+
+```sh
+make # list the targets
+make test # run the suite
+make lint # luacheck when installed, else luac -p; touches nothing
+make format # stylua, when installed (writes)
+make build # byte-compile every module into build/
+make check # lint, then test, then build
+```
+
+The suite stands up a real HTTP server on loopback and points the client at it
+through `base_url`; nothing is stubbed and nothing touches the network. Run it
+on another interpreter with `make test LUA=luajit`.
+
+## Licence
+
+ISC. See [LICENSE](LICENSE).