diff options
Diffstat (limited to 'README.md')
| -rw-r--r-- | README.md | 253 |
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). |