summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:42:08 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:42:08 -0300
commit7fd555fcaf4304cb0dff3e8f5661565c3618299e (patch)
tree61199793c3046d73b97e9822895ede2a33034d41
downloadlua-0.1.0.tar.gz
Initial importv0.1.0
-rw-r--r--.gitignore8
-rw-r--r--LICENSE15
-rw-r--r--Makefile63
-rw-r--r--README.md253
-rw-r--r--cepis-0.1.0-1.rockspec46
-rw-r--r--cepis.lua157
-rw-r--r--cepis/client.lua609
-rw-r--r--cepis/csv.lua109
-rw-r--r--cepis/errors.lua115
-rw-r--r--cepis/models.lua226
-rw-r--r--test/harness.lua205
-rw-r--r--test/run.lua506
-rw-r--r--test/server.lua182
13 files changed, 2494 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..302805c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+build/
+*.luac
+*.rock
+*.so
+.luarocks/
+lua_modules/
+luacov.stats.out
+luacov.report.out
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -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..d3c470d
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,63 @@
+# Development tasks.
+#
+# make list the targets
+# make test run the suite
+# make lint luacheck if installed, otherwise luac -p; touches nothing
+# make build byte-compile every module into build/
+#
+# LUA pins the interpreter. It defaults to `lua`; override it to run the suite
+# on another one, e.g. `make test LUA=lua5.4` or `make test LUA=luajit`.
+LUA ?= lua
+LUAC ?= luac
+
+SOURCES := cepis.lua $(wildcard cepis/*.lua)
+ALL_LUA := $(SOURCES) $(wildcard test/*.lua)
+
+.DEFAULT_GOAL := help
+.PHONY: help test lint format build check clean
+
+help:
+ @echo 'Targets:'
+ @echo ' test run the test suite (LUA=$(LUA))'
+ @echo ' lint luacheck when installed, else $(LUAC) -p (read-only)'
+ @echo ' format apply stylua when installed (writes)'
+ @echo ' build byte-compile every module into build/'
+ @echo ' check lint + test + build, in that order'
+ @echo ' clean remove build artefacts'
+ @echo
+ @echo 'Override the interpreter with e.g. make test LUA=luajit'
+
+test:
+ @$(LUA) test/run.lua
+
+# Read-only by design, so it is safe in CI and in a pre-commit hook. luacheck
+# is the real linter; luac -p is the fallback that needs nothing installed and
+# still catches a syntax error before the suite does.
+lint:
+ @if command -v luacheck >/dev/null 2>&1; then \
+ luacheck $(ALL_LUA); \
+ else \
+ echo '==> luacheck not installed, checking syntax with $(LUAC) -p'; \
+ $(LUAC) -p $(ALL_LUA) && echo 'syntax ok'; \
+ fi
+
+format:
+ @if command -v stylua >/dev/null 2>&1; then \
+ stylua $(ALL_LUA); \
+ else \
+ echo '==> stylua not installed, nothing was formatted'; \
+ fi
+
+build:
+ @mkdir -p build
+ @for source in $(SOURCES); do \
+ $(LUAC) -o "build/$$(echo $$source | tr / _)c" "$$source" || exit 1; \
+ done
+ @echo 'byte-compiled into build/'
+
+# The order matters: lint is the fastest to fail, so a broken run reports the
+# cheapest problem first.
+check: lint test build
+
+clean:
+ rm -rf build
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).
diff --git a/cepis-0.1.0-1.rockspec b/cepis-0.1.0-1.rockspec
new file mode 100644
index 0000000..c303f96
--- /dev/null
+++ b/cepis-0.1.0-1.rockspec
@@ -0,0 +1,46 @@
+rockspec_format = "3.0"
+package = "cepis"
+version = "0.1.0-1"
+
+source = {
+ url = "git+https://git.cep.is/lua.git",
+ tag = "v0.1.0",
+}
+
+description = {
+ summary = "Lua client for the public CEP API at cep.is",
+ detailed = [[
+ Looks a Brazilian CEP up against https://cep.is, which serves the official
+ Correios database (DNE) refreshed every two weeks, with coordinates for
+ about 57% of them. Single lookups, batches of up to 500 per request, and a
+ health endpoint. Retries 429 and 5xx with exponential backoff and full
+ jitter; never retries 400 or 404.
+ ]],
+ homepage = "https://cep.is",
+ license = "ISC",
+ labels = { "cep", "correios", "dne", "endereco", "brasil", "http" },
+}
+
+dependencies = {
+ "lua >= 5.1",
+ "luasocket >= 3.0",
+ "luasec >= 1.0",
+ "dkjson >= 2.5",
+}
+
+build = {
+ type = "builtin",
+ modules = {
+ ["cepis"] = "cepis.lua",
+ ["cepis.client"] = "cepis/client.lua",
+ ["cepis.csv"] = "cepis/csv.lua",
+ ["cepis.errors"] = "cepis/errors.lua",
+ ["cepis.models"] = "cepis/models.lua",
+ },
+ copy_directories = {},
+}
+
+test = {
+ type = "command",
+ command = "lua test/run.lua",
+}
diff --git a/cepis.lua b/cepis.lua
new file mode 100644
index 0000000..e120af1
--- /dev/null
+++ b/cepis.lua
@@ -0,0 +1,157 @@
+--- Lua client for the public CEP API at [cep.is](https://cep.is).
+--
+-- Quick use — for a script, the module-level functions are enough:
+--
+-- local cepis = require("cepis")
+--
+-- local address, err = cepis.lookup("01001-000")
+-- print(address.localidade, address.uf) --> São Paulo SP
+--
+-- In an application — build a client once and reuse it:
+--
+-- local client = cepis.new({ timeout = 5, total_timeout = 15 })
+-- local address, err = client:lookup("01001000")
+--
+-- Pointing at a local instance (the API repo brings one up with `just up`):
+--
+-- local client = cepis.new({ base_url = "http://localhost:8080" })
+--
+-- Expected failures are returned, never thrown, and every error carries a
+-- `kind` so the caller branches on one string instead of matching messages:
+--
+-- local address, err = cepis.lookup(cep_from_form)
+-- if not address then
+-- if err.kind == cepis.NOT_FOUND then
+-- -- the CEP does not exist: ask the user for another
+-- elseif err:is_retriable() then
+-- -- network, server, rate limit: reschedule
+-- end
+-- end
+--
+-- `error()` is reserved for programmer mistakes — a bad option table, a wrong
+-- argument type. Those are bugs to fix, not conditions to handle.
+--
+-- 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 index on the table.
+--
+-- The module-level functions share one default client, built on first use.
+-- It applies process-wide, including to any other library that also requires
+-- this module. Anyone needing different settings should build their own.
+--
+-- @module cepis
+
+local client = require("cepis.client")
+local errors = require("cepis.errors")
+local models = require("cepis.models")
+
+local M = {}
+
+--- Library version.
+M._VERSION = client.VERSION
+
+--- Where the API lives unless `base_url` says otherwise: `https://cep.is`.
+M.DEFAULT_URL = client.DEFAULT_URL
+
+--- How many CEPs the batch endpoint accepts in one request: `500`.
+M.MAX_BATCH = client.MAX_BATCH
+
+--- The `Client` class. `cepis.new` is the shorthand for `Client.new`.
+M.Client = client.Client
+
+--- The `Address` metatable, for `getmetatable(x) == cepis.Address`.
+M.Address = models.Address
+
+--- The `Geo` metatable.
+M.Geo = models.Geo
+
+--- The `Health` metatable.
+M.Health = models.Health
+
+--- Error kind: the CEP is not 8 digits. No request was made.
+M.INVALID_CEP = errors.INVALID_CEP
+
+--- Error kind: well-formed, but absent from the DNE database.
+M.NOT_FOUND = errors.NOT_FOUND
+
+--- Error kind: 429 on every attempt. `err.retry_after` may hold seconds.
+M.RATE_LIMITED = errors.RATE_LIMITED
+
+--- Error kind: 5xx on every attempt. `err.status` holds the last one seen.
+M.SERVER_ERROR = errors.SERVER_ERROR
+
+--- Error kind: the request never completed — DNS, connection, timeout, TLS.
+M.NETWORK_ERROR = errors.NETWORK_ERROR
+
+--- Error kind: a success status with a body that cannot be parsed.
+M.INVALID_RESPONSE = errors.INVALID_RESPONSE
+
+--- Every error kind, keyed by itself.
+M.kinds = errors.kinds
+
+--- Whether a value is one of this library's errors.
+-- @param value anything
+-- @treturn boolean
+M.is_error = errors.is
+
+--- Build a client. See `cepis.client.Client.new` for every option.
+-- @tparam[opt] table opts
+-- @treturn Client
+M.new = client.Client.new
+
+--- Return the CEP's 8 digits, accepting the mask.
+-- @tparam string value
+-- @treturn[1] string
+-- @treturn[2] nil
+-- @treturn[2] table the error
+M.normalize_cep = client.normalize_cep
+
+local default
+
+--- The shared client behind the module-level functions.
+--
+-- Built on first use with every default. Do not mutate it: it applies
+-- process-wide.
+-- @treturn Client
+function M.default()
+ if not default then
+ default = client.Client.new()
+ end
+ return default
+end
+
+--- Look up a CEP using the default configuration.
+--
+-- Shorthand for `cepis.default():lookup(cep)`.
+-- @tparam string cep masked or unmasked
+-- @treturn[1] Address
+-- @treturn[2] nil
+-- @treturn[2] table the error
+function M.lookup(cep)
+ return M.default():lookup(cep)
+end
+
+--- Look up many CEPs in one batch, using the default configuration.
+--
+-- Shorthand for `cepis.default():lookup_many(ceps)` — read that one, in
+-- particular why a missing CEP is `false` and not `nil`, and the warning that
+-- batches carry no coordinate.
+-- @tparam table ceps list of strings
+-- @treturn[1] table list of `Address|false`
+-- @treturn[2] nil
+-- @treturn[2] table the error
+function M.lookup_many(ceps)
+ return M.default():lookup_many(ceps)
+end
+
+--- Query the API's health using the default configuration.
+--
+-- Shorthand for `cepis.default():health()`.
+-- @treturn[1] Health
+-- @treturn[2] nil
+-- @treturn[2] table the error
+function M.health()
+ return M.default():health()
+end
+
+return M
diff --git a/cepis/client.lua b/cepis/client.lua
new file mode 100644
index 0000000..2a8ae2c
--- /dev/null
+++ b/cepis/client.lua
@@ -0,0 +1,609 @@
+--- The HTTP client, built on LuaSocket (and LuaSec for `https://`).
+--
+-- Each request opens a fresh TCP+TLS connection; nothing is pooled. For a
+-- one-off lookup that is irrelevant. For thousands, **do not write a loop** —
+-- use `Client:lookup_many`, which resolves up to 500 CEPs per request. A loop
+-- of 500 calls pays 500 TLS handshakes; the batch pays one.
+--
+-- Retry policy: 429 and the transient 5xx, 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's quota.
+--
+-- @module cepis.client
+
+local socket = require("socket")
+local http = require("socket.http")
+local ltn12 = require("ltn12")
+local json = require("dkjson")
+
+local errors = require("cepis.errors")
+local models = require("cepis.models")
+local csv = require("cepis.csv")
+
+local M = {}
+
+--- Library version, as it appears in the default `User-Agent`.
+M.VERSION = "0.1.0"
+
+--- Where the API lives unless `base_url` says otherwise.
+M.DEFAULT_URL = "https://cep.is"
+
+--- How many CEPs the batch endpoint accepts in one request.
+M.MAX_BATCH = 500
+
+local ACCEPT = "application/json, text/csv;q=0.9, */*;q=0.1"
+
+local RETRIABLE_STATUS = {
+ [429] = true,
+ [500] = true,
+ [502] = true,
+ [503] = true,
+ [504] = true,
+}
+
+local MONTHS = {
+ Jan = 1,
+ Feb = 2,
+ Mar = 3,
+ Apr = 4,
+ May = 5,
+ Jun = 6,
+ Jul = 7,
+ Aug = 8,
+ Sep = 9,
+ Oct = 10,
+ Nov = 11,
+ Dec = 12,
+}
+
+local ARRAY_MT = {}
+
+if _VERSION < "Lua 5.4" then
+ math.randomseed(os.time())
+end
+
+local function quote(value)
+ local text = tostring(value)
+ if #text > 60 then
+ text = text:sub(1, 60) .. "..."
+ end
+ return "'" .. (text:gsub("%c", " ")) .. "'"
+end
+
+local function urlescape(value)
+ return (value:gsub("[^%w%-%.%_%~]", function(char)
+ return string.format("%%%02X", string.byte(char))
+ end))
+end
+
+local function utc_to_epoch(fields)
+ fields.isdst = false
+ local as_local = os.time(fields)
+ if not as_local then
+ return nil
+ end
+ local utc = os.date("!*t", as_local)
+ utc.isdst = false
+ return as_local + os.difftime(as_local, os.time(utc))
+end
+
+--- Seconds to wait, from a `Retry-After` header value.
+--
+-- RFC 9110 allows two forms: a number of seconds (`"120"`) and 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.
+--
+-- @tparam string|nil value the raw header
+-- @treturn number|nil seconds, never negative, or `nil` if unparseable
+function M.retry_after_seconds(value)
+ if type(value) ~= "string" then
+ return nil
+ end
+
+ value = value:match("^%s*(.-)%s*$")
+ if value == "" then
+ return nil
+ end
+
+ local seconds = tonumber(value)
+ if seconds then
+ return math.max(0, seconds)
+ end
+
+ local day, month, year, hour, minute, second =
+ value:match("^%a+,%s+(%d+)%s+(%a+)%s+(%d+)%s+(%d+):(%d+):(%d+)%s+GMT$")
+ if not day then
+ day, month, year, hour, minute, second =
+ value:match("^(%d+)%s+(%a+)%s+(%d+)%s+(%d+):(%d+):(%d+)%s+GMT$")
+ end
+ if not day or not MONTHS[month] then
+ return nil
+ end
+
+ local target = utc_to_epoch({
+ year = tonumber(year),
+ month = MONTHS[month],
+ day = tonumber(day),
+ hour = tonumber(hour),
+ min = tonumber(minute),
+ sec = tonumber(second),
+ })
+ if not target then
+ return nil
+ end
+
+ return math.max(0, target - os.time())
+end
+
+--- Return the CEP's 8 digits, accepting the mask.
+--
+-- `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`.
+--
+-- Returns `nil, err` with `err.kind == "invalid_cep"` 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.
+--
+-- @tparam string value masked or unmasked
+-- @treturn[1] string the 8 digits
+-- @treturn[2] nil
+-- @treturn[2] table the error
+function M.normalize_cep(value)
+ if type(value) ~= "string" then
+ return nil,
+ errors.new(errors.INVALID_CEP, "CEP must be a string, got " .. type(value))
+ end
+
+ local cleaned = value:gsub("[%s%.%-]", "")
+ if not cleaned:match("^%d%d%d%d%d%d%d%d$") then
+ return nil,
+ errors.new(
+ errors.INVALID_CEP,
+ "CEP must be 8 digits, got " .. quote(value)
+ )
+ end
+
+ return cleaned
+end
+
+local function decode_object(body)
+ local data = json.decode(body, 1, nil, nil, ARRAY_MT)
+ if type(data) ~= "table" or getmetatable(data) == ARRAY_MT then
+ return nil,
+ errors.new(
+ errors.INVALID_RESPONSE,
+ "response is not a JSON object — something between you and the API is "
+ .. "answering instead of it: "
+ .. quote(body)
+ )
+ end
+ return data
+end
+
+local function error_message(body, fallback)
+ local ok, data = pcall(json.decode, body or "", 1, nil)
+ if ok and type(data) == "table" and type(data.mensagem) == "string" then
+ if data.mensagem ~= "" then
+ return data.mensagem
+ end
+ end
+ return fallback
+end
+
+local function transport(url)
+ if url:lower():find("^https://") then
+ local ok, https = pcall(require, "ssl.https")
+ if not ok then
+ return nil,
+ "an https:// base_url needs LuaSec (luarocks install luasec); "
+ .. "use an http:// base_url to avoid it"
+ end
+ return https.request
+ end
+ return http.request
+end
+
+--- Client for the cep.is API.
+--
+-- Reuse one instance: the configuration is not mutated and no method keeps
+-- state between calls.
+--
+-- @type Client
+-- @field base_url string
+-- @field timeout number
+-- @field max_attempts number
+-- @field backoff_base number
+-- @field backoff_max number
+-- @field total_timeout number|nil
+-- @field user_agent string
+local Client = {}
+Client.__index = Client
+Client.__name = "cepis.Client"
+
+M.Client = Client
+
+--- Build a client.
+--
+-- Every option has a usable default, so `Client.new()` is valid. A bad option
+-- raises through `error()` rather than returning one: it is a mistake in the
+-- calling code, not a condition to handle at runtime.
+--
+-- @tparam[opt] table opts
+-- @tparam[opt="https://cep.is"] string opts.base_url
+-- 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 option that usually changes between environments. Trailing
+-- slashes are stripped, so it cannot produce doubled separators that some
+-- proxies normalise and others 404.
+-- @tparam[opt=10] number opts.timeout
+-- Seconds to wait **per socket operation** (connect, and each read) — this
+-- is not a cap on the whole call. That cap is `total_timeout`.
+-- @tparam[opt=3] number opts.max_attempts
+-- 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.
+-- @tparam[opt=0.5] number opts.backoff_base
+-- Base of the exponential wait, in seconds. 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.
+-- @tparam[opt=8] number opts.backoff_max
+-- Cap on each individual wait, in seconds. It applies to a server-sent
+-- `Retry-After` too, or a `Retry-After: 3600` from a misconfigured proxy
+-- would park the caller for an hour.
+-- @tparam[opt] number opts.total_timeout
+-- Cap on the whole call, attempts and waits included. `nil` leaves
+-- `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.
+-- @tparam[opt] string opts.user_agent
+-- Overrides the `User-Agent`. The default identifies the library and its
+-- version, which is what lets the server diagnose an old client
+-- misbehaving.
+-- @treturn Client
+function Client.new(opts)
+ opts = opts or {}
+
+ local base_url = opts.base_url or M.DEFAULT_URL
+ local timeout = opts.timeout or 10.0
+ local max_attempts = opts.max_attempts or 3
+ local backoff_base = opts.backoff_base or 0.5
+ local backoff_max = opts.backoff_max or 8.0
+ local total_timeout = opts.total_timeout
+
+ if type(base_url) ~= "string" or base_url == "" then
+ error("base_url must be a non-empty string", 2)
+ end
+ if type(timeout) ~= "number" or timeout <= 0 then
+ error("timeout must be a number > 0", 2)
+ end
+ if type(max_attempts) ~= "number" or max_attempts < 1 then
+ error("max_attempts must be a number >= 1", 2)
+ end
+ if type(backoff_base) ~= "number" or backoff_base < 0 then
+ error("backoff_base must be a number >= 0", 2)
+ end
+ if type(backoff_max) ~= "number" or backoff_max < 0 then
+ error("backoff_max must be a number >= 0", 2)
+ end
+ if total_timeout ~= nil and (type(total_timeout) ~= "number" or total_timeout <= 0) then
+ error("total_timeout must be a number > 0, or nil", 2)
+ end
+ if opts.user_agent ~= nil and type(opts.user_agent) ~= "string" then
+ error("user_agent must be a string", 2)
+ end
+
+ return setmetatable({
+ base_url = (base_url:gsub("/+$", "")),
+ timeout = timeout,
+ max_attempts = math.floor(max_attempts),
+ backoff_base = backoff_base,
+ backoff_max = backoff_max,
+ total_timeout = total_timeout,
+ user_agent = opts.user_agent
+ or ("cepis-lua/" .. M.VERSION .. " (+https://cep.is)"),
+ }, Client)
+end
+
+function Client:_perform(url, request)
+ local chunks = {}
+ local previous = http.TIMEOUT
+ http.TIMEOUT = self.timeout
+
+ local called, ok, code, headers = pcall(request, {
+ url = url,
+ method = "GET",
+ headers = {
+ ["user-agent"] = self.user_agent,
+ ["accept"] = ACCEPT,
+ ["accept-encoding"] = "identity",
+ },
+ sink = ltn12.sink.table(chunks),
+ redirect = false,
+ })
+
+ http.TIMEOUT = previous
+
+ if not called then
+ return nil, tostring(ok)
+ end
+ if not ok or type(code) ~= "number" then
+ return nil, tostring(code)
+ end
+
+ return code, table.concat(chunks), headers or {}
+end
+
+function Client:_backoff(attempt, retry_after)
+ if retry_after then
+ return math.min(retry_after, self.backoff_max)
+ end
+ local ceiling = math.min(self.backoff_base * 2 ^ (attempt - 1), self.backoff_max)
+ return math.random() * ceiling
+end
+
+function Client:_request(path, accepted)
+ accepted = accepted or { [200] = true }
+
+ local url = self.base_url .. path
+
+ local request, unavailable = transport(url)
+ if not request then
+ return nil, errors.new(errors.NETWORK_ERROR, "cannot reach " .. url .. ": " .. unavailable)
+ end
+
+ local deadline = nil
+ if self.total_timeout then
+ deadline = socket.gettime() + self.total_timeout
+ end
+
+ local last_status, last_retry_after, last_network
+
+ for attempt = 1, self.max_attempts do
+ if deadline and socket.gettime() >= deadline then
+ break
+ end
+
+ local status, body, headers = self:_perform(url, request)
+
+ if status then
+ if accepted[status] or not RETRIABLE_STATUS[status] then
+ return status, body, headers
+ end
+ last_status = status
+ last_retry_after = M.retry_after_seconds(headers["retry-after"])
+ last_network = nil
+ else
+ last_network = body
+ last_status = nil
+ last_retry_after = nil
+ end
+
+ if attempt == self.max_attempts then
+ break
+ end
+
+ local wait = self:_backoff(attempt, last_retry_after)
+ if deadline then
+ local remaining = deadline - socket.gettime()
+ if remaining <= 0 then
+ break
+ end
+ if wait > remaining then
+ wait = remaining
+ end
+ end
+ socket.sleep(wait)
+ end
+
+ local suffix = string.format(" (after %d attempt(s))", self.max_attempts)
+
+ if last_status == 429 then
+ return nil,
+ errors.new(errors.RATE_LIMITED, "rate limit reached at " .. url .. suffix, {
+ status = 429,
+ retry_after = last_retry_after,
+ })
+ end
+ if last_status then
+ return nil,
+ errors.new(
+ errors.SERVER_ERROR,
+ string.format("the API answered %d at %s%s", last_status, url, suffix),
+ { status = last_status }
+ )
+ end
+ return nil,
+ errors.new(
+ errors.NETWORK_ERROR,
+ "could not reach " .. url .. suffix .. ": " .. tostring(last_network)
+ )
+end
+
+--- Look up a single CEP.
+--
+-- local address, err = client:lookup("01001-000")
+-- print(address.localidade, address.uf) --> São Paulo SP
+--
+-- A 400 from the API surfaces as `invalid_cep` carrying the API's own message:
+-- the format already passed local validation, so the server knows something
+-- this client does not.
+--
+-- @tparam string cep masked or unmasked
+-- @treturn[1] Address
+-- @treturn[2] nil
+-- @treturn[2] table the error; `kind` is one of `invalid_cep` (not 8 digits —
+-- no request is made), `not_found` (well-formed, absent from the database),
+-- `rate_limited`, `server_error`, `network_error`, `invalid_response`
+function Client:lookup(cep)
+ local number, invalid = M.normalize_cep(cep)
+ if not number then
+ return nil, invalid
+ end
+
+ local status, body = self:_request("/" .. number)
+ if not status then
+ return nil, body
+ end
+
+ if status == 404 then
+ return nil,
+ errors.new(
+ errors.NOT_FOUND,
+ "CEP " .. number .. " does not exist in the DNE database",
+ { cep = number, status = 404 }
+ )
+ end
+ if status == 400 then
+ return nil,
+ errors.new(
+ errors.INVALID_CEP,
+ error_message(body, "CEP " .. number .. " rejected by the API"),
+ { cep = number, status = 400 }
+ )
+ end
+
+ local data, malformed = decode_object(body)
+ if not data then
+ return nil, malformed
+ end
+
+ return models.new_address(data)
+end
+
+function Client:_lookup_chunk(ceps)
+ local wanted = #ceps
+ local query = "ceps=" .. urlescape(table.concat(ceps, ","))
+
+ local status, body = self:_request("/csv?" .. query)
+ if not status then
+ return nil, body
+ end
+
+ local rows = csv.parse(body)
+ local out = {}
+ local count = 0
+
+ if #rows > 0 then
+ local header = rows[1]
+ for index = 2, #rows do
+ if count >= wanted then
+ break
+ end
+ local record = {}
+ for column = 1, #header do
+ record[header[column]] = rows[index][column] or ""
+ end
+ count = count + 1
+ if record.localidade == nil or record.localidade:match("^%s*$") then
+ out[count] = false
+ else
+ out[count] = models.new_address(record)
+ end
+ end
+ end
+
+ for index = count + 1, wanted do
+ out[index] = false
+ end
+
+ return out
+end
+
+--- Look up many CEPs in one batch.
+--
+-- Returns a list of **the same length and in the same order** as the input.
+--
+-- **A CEP that does not exist is `false` at its position, not `nil`.** This is
+-- the one place where Lua forces a difference from the other clients in this
+-- family, and it is not cosmetic: a `nil` inside a sequence truncates it at the
+-- first hole, so `#result` and `ipairs` would silently stop at the first
+-- missing CEP and drop every address after it. `false` keeps the sequence
+-- dense, and `if address then` still reads as the natural test:
+--
+-- local result = client:lookup_many({ "01001000", "99999999", "20040020" })
+-- for index, address in ipairs(result) do
+-- if address then
+-- print(ceps[index], address.localidade)
+-- else
+-- print(ceps[index], "not found")
+-- end
+-- end
+--
+-- A malformed CEP is `false` too, for the same reason, rather than failing the
+-- call: in a batch of a thousand, one bad CEP must not cost the other nine
+-- hundred and ninety-nine. If you need to know *which* one was malformed,
+-- validate first with `normalize_cep`.
+--
+-- Input is chunked into blocks of 500 (the API's limit) automatically.
+--
+-- **The batch carries no coordinate.** The endpoint behind it is CSV, and the
+-- CSV has no `geo` column. Every `Address` returned here has `geo == nil`,
+-- including for CEPs that do have one. Need `geo`? Use `lookup`, one at a
+-- time.
+--
+-- @tparam table ceps list of strings
+-- @treturn[1] table list of `Address|false`, same length and order as `ceps`
+-- @treturn[2] nil
+-- @treturn[2] table the error, if the batch request itself failed
+function Client:lookup_many(ceps)
+ if type(ceps) ~= "table" then
+ error("lookup_many expects a table of CEPs, got " .. type(ceps), 2)
+ end
+
+ local total = #ceps
+ if total == 0 then
+ return {}
+ end
+
+ local result = {}
+ local filled = 0
+ local start = 1
+
+ while start <= total do
+ local stop = math.min(start + M.MAX_BATCH - 1, total)
+ local chunk = {}
+ for index = start, stop do
+ chunk[#chunk + 1] = tostring(ceps[index])
+ end
+
+ local rows, err = self:_lookup_chunk(chunk)
+ if not rows then
+ return nil, err
+ end
+
+ for index = 1, #chunk do
+ filled = filled + 1
+ result[filled] = rows[index]
+ end
+ start = stop + 1
+ end
+
+ return result
+end
+
+--- 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 return `nil, err`.
+--
+-- @treturn[1] Health
+-- @treturn[2] nil
+-- @treturn[2] table the error
+function Client:health()
+ local status, body = self:_request("/health", { [200] = true, [503] = true })
+ if not status then
+ return nil, body
+ end
+
+ local data, malformed = decode_object(body)
+ if not data then
+ return nil, malformed
+ end
+
+ return models.new_health(data)
+end
+
+return M
diff --git a/cepis/csv.lua b/cepis/csv.lua
new file mode 100644
index 0000000..be05c9a
--- /dev/null
+++ b/cepis/csv.lua
@@ -0,0 +1,109 @@
+--- A minimal RFC 4180 reader, for the batch endpoint.
+--
+-- The values the API sends may contain commas — a `logradouro` such as
+-- `Rua Um, Dois` — and are then double-quoted with `""` escaping. Splitting on
+-- `,` silently misaligns every column after the first such value, which is why
+-- this exists instead of a `gmatch`.
+--
+-- @module cepis.csv
+
+local M = {}
+
+local BOM = "\239\187\191"
+
+--- Parse CSV text into a list of rows, each a list of field strings.
+--
+-- Handles quoted fields, `""` escapes, embedded commas and newlines, both LF
+-- and CRLF line endings, and a leading UTF-8 BOM. Blank lines yield no row.
+-- Nothing is coerced: every field comes back as a string.
+--
+-- Text between a closing quote and the next delimiter is appended to the field
+-- rather than starting a new one: malformed input must not silently gain a
+-- column and shift every value after it.
+--
+-- @tparam string text the response body
+-- @treturn table list of rows; each row is a list of strings
+function M.parse(text)
+ if text:sub(1, 3) == BOM then
+ text = text:sub(4)
+ end
+
+ local rows = {}
+ local row = {}
+ local count = 0
+ local pos = 1
+ local size = #text
+
+ local function close_row()
+ if not (count == 1 and row[1] == "") then
+ rows[#rows + 1] = row
+ end
+ row = {}
+ count = 0
+ end
+
+ while pos <= size do
+ local field
+ if text:sub(pos, pos) == '"' then
+ local parts = {}
+ pos = pos + 1
+ while true do
+ local quote = text:find('"', pos, true)
+ if not quote then
+ parts[#parts + 1] = text:sub(pos)
+ pos = size + 1
+ break
+ end
+ parts[#parts + 1] = text:sub(pos, quote - 1)
+ if text:sub(quote + 1, quote + 1) == '"' then
+ parts[#parts + 1] = '"'
+ pos = quote + 2
+ else
+ pos = quote + 1
+ break
+ end
+ end
+ local trailing = text:find("[,\r\n]", pos)
+ if trailing ~= pos then
+ parts[#parts + 1] = text:sub(pos, (trailing or size + 1) - 1)
+ pos = trailing or size + 1
+ end
+ field = table.concat(parts)
+ else
+ local stop = text:find("[,\r\n]", pos)
+ if stop then
+ field = text:sub(pos, stop - 1)
+ pos = stop
+ else
+ field = text:sub(pos)
+ pos = size + 1
+ end
+ end
+
+ count = count + 1
+ row[count] = field
+
+ local char = text:sub(pos, pos)
+ if char == "," then
+ pos = pos + 1
+ if pos > size then
+ count = count + 1
+ row[count] = ""
+ close_row()
+ end
+ elseif char == "\r" or char == "\n" then
+ if char == "\r" and text:sub(pos + 1, pos + 1) == "\n" then
+ pos = pos + 2
+ else
+ pos = pos + 1
+ end
+ close_row()
+ else
+ close_row()
+ end
+ end
+
+ return rows
+end
+
+return M
diff --git a/cepis/errors.lua b/cepis/errors.lua
new file mode 100644
index 0000000..0076b95
--- /dev/null
+++ b/cepis/errors.lua
@@ -0,0 +1,115 @@
+--- Error values returned by the `nil, err` convention.
+--
+-- Every failure this library expects to happen is returned, never thrown:
+-- `local address, err = client:lookup(cep)`. Only programmer mistakes — a bad
+-- option table, a wrong argument type where no sensible value exists — raise
+-- through `error()`.
+--
+-- An error is a table with a `kind` field, so a caller branches on one string
+-- instead of matching messages. The split is by **the decision the caller has
+-- to make**, not by the HTTP status that caused it:
+--
+-- - `invalid_cep` and `not_found` are problems with the *request*. Retrying is
+-- pointless — the same CEP will fail again.
+-- - `network_error`, `server_error` and `rate_limited` are problems with
+-- *transport*. Retrying may help, and the client already retried on its own
+-- before returning any of them.
+--
+-- That split is why `not_found` is not "the HTTP 404 error": the client also
+-- returns `invalid_cep` without making any request at all, when the CEP is
+-- malformed before it ever leaves the machine.
+--
+-- @module cepis.errors
+
+local M = {}
+
+--- The CEP is not 8 digits. Usually returned without any request being made.
+M.INVALID_CEP = "invalid_cep"
+
+--- The CEP is well-formed but does not exist in the DNE database.
+M.NOT_FOUND = "not_found"
+
+--- The API (or the edge in front of it) answered 429 on every attempt.
+M.RATE_LIMITED = "rate_limited"
+
+--- The API answered 5xx on every attempt.
+M.SERVER_ERROR = "server_error"
+
+--- The request never completed: DNS, refused connection, timeout, TLS.
+M.NETWORK_ERROR = "network_error"
+
+--- 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.
+M.INVALID_RESPONSE = "invalid_response"
+
+--- Every kind, keyed by itself, for validation and for iterating.
+M.kinds = {
+ [M.INVALID_CEP] = true,
+ [M.NOT_FOUND] = true,
+ [M.RATE_LIMITED] = true,
+ [M.SERVER_ERROR] = true,
+ [M.NETWORK_ERROR] = true,
+ [M.INVALID_RESPONSE] = true,
+}
+
+local RETRIABLE = {
+ [M.RATE_LIMITED] = true,
+ [M.SERVER_ERROR] = true,
+ [M.NETWORK_ERROR] = true,
+}
+
+--- Metatable of every error this library returns.
+-- @type Error
+local Error = {}
+Error.__index = Error
+Error.__name = "cepis.Error"
+
+function Error.__tostring(self)
+ return self.message
+end
+
+--- Whether trying the same call again later could plausibly succeed.
+--
+-- True for `rate_limited`, `server_error` and `network_error`; false for
+-- `invalid_cep`, `not_found` and `invalid_response`. The client has already
+-- exhausted its own attempts by the time it returns any of them, so this
+-- answers "should I reschedule this work?", not "should I loop right now?".
+-- @treturn boolean
+function Error:is_retriable()
+ return RETRIABLE[self.kind] == true
+end
+
+M.Error = Error
+
+--- Build an error value.
+--
+-- @tparam string kind one of the six constants in this module
+-- @tparam string message human-readable, already complete
+-- @tparam[opt] table fields extra context: `cep`, `status`, `retry_after`
+-- @treturn table the error, with `tostring()` yielding `message`
+function M.new(kind, message, fields)
+ local err = setmetatable({}, Error)
+ if fields then
+ for key, value in pairs(fields) do
+ err[key] = value
+ end
+ end
+ err.kind = kind
+ err.message = message
+ return err
+end
+
+--- Whether a value is one of this library's errors.
+--
+-- Use it to tell a `cepis` error apart from any other value a `nil, err`
+-- boundary might hand you.
+-- @param value anything
+-- @treturn boolean
+function M.is(value)
+ return getmetatable(value) == Error
+end
+
+return M
diff --git a/cepis/models.lua b/cepis/models.lua
new file mode 100644
index 0000000..61ad678
--- /dev/null
+++ b/cepis/models.lua
@@ -0,0 +1,226 @@
+--- The data the API returns, as tables.
+--
+-- **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`,
+-- `localidade`. Everything else in this library 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 index on the table. Translating to `street`/`city` would
+-- mean maintaining a field-by-field mapping and forcing anyone reading the API
+-- docs alongside this library to translate back in their head. The values are
+-- Portuguese either way — they come from the Correios database.
+--
+-- @module cepis.models
+
+local M = {}
+
+local function text(value)
+ if value == nil then
+ return ""
+ end
+ if type(value) == "string" then
+ return value
+ end
+ if type(value) == "table" then
+ return ""
+ end
+ return tostring(value)
+end
+
+local function integer(value)
+ local number = tonumber(value)
+ if not number then
+ return 0
+ end
+ if number < 0 then
+ return -math.floor(-number)
+ end
+ return math.floor(number)
+end
+
+--- 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` 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.
+--
+-- @type Geo
+-- @field lat number
+-- @field lon number
+-- @field raio_m number
+-- @field precisao string `"logradouro"` or `"localidade"`
+local Geo = {}
+Geo.__index = Geo
+Geo.__name = "cepis.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.
+-- @treturn boolean
+function Geo:is_precise()
+ return self.precisao == "logradouro"
+end
+
+M.Geo = Geo
+
+local function build_geo(block)
+ if type(block) ~= "table" then
+ return nil
+ end
+
+ local lat = tonumber(block.lat)
+ local lon = tonumber(block.lon)
+ local raio = tonumber(block.raio_m)
+ local precisao = block.precisao
+
+ if not lat or not lon or not raio then
+ return nil
+ end
+ if precisao == nil or type(precisao) == "table" then
+ return nil
+ end
+
+ return setmetatable({
+ lat = lat + 0.0,
+ lon = lon + 0.0,
+ raio_m = integer(raio),
+ precisao = tostring(precisao),
+ }, Geo)
+end
+
+--- A CEP and everything the database knows about it.
+--
+-- Fields the database does not have for that CEP come back as empty strings,
+-- never `nil` — that is what the API returns, and changing it here would
+-- create a difference between reading the JSON and reading this library.
+--
+-- `geo` is the exception: it is `nil` 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` holds the response exactly as it arrived, uninterpreted, so a field
+-- the API grows before this library is updated is not lost.
+--
+-- @type Address
+-- @field cep string always the 8 digits, unmasked
+-- @field logradouro string
+-- @field complemento string
+-- @field unidade string
+-- @field bairro string
+-- @field localidade string
+-- @field uf string
+-- @field estado string
+-- @field regiao string
+-- @field ibge string
+-- @field gia string
+-- @field ddd string
+-- @field siafi string
+-- @field geo Geo|nil
+-- @field raw table the decoded response, untouched
+local Address = {}
+Address.__index = Address
+Address.__name = "cepis.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 `string.format` in every
+-- project that uses this.
+-- @treturn string
+function Address:formatted_cep()
+ if #self.cep ~= 8 then
+ return self.cep
+ end
+ return self.cep:sub(1, 5) .. "-" .. self.cep:sub(6)
+end
+
+M.Address = Address
+
+local FIELDS = {
+ "cep",
+ "logradouro",
+ "complemento",
+ "unidade",
+ "bairro",
+ "localidade",
+ "uf",
+ "estado",
+ "regiao",
+ "ibge",
+ "gia",
+ "ddd",
+ "siafi",
+}
+
+--- Build an `Address` from the decoded response.
+--
+-- Unknown fields are ignored during construction but preserved in `raw`. A new
+-- field in the API therefore cannot break this library.
+--
+-- A malformed `geo` block yields `geo = nil` rather than failing the whole
+-- call: it is not worth losing the address over, since the rest of the
+-- response is still useful.
+--
+-- @tparam table data decoded JSON object, or a CSV row keyed by column name
+-- @treturn Address
+function M.new_address(data)
+ local address = setmetatable({}, Address)
+ for _, name in ipairs(FIELDS) do
+ address[name] = text(data[name])
+ end
+ address.geo = build_geo(data.geo)
+ address.raw = data
+ return address
+end
+
+--- 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` identifies it.
+--
+-- @type Health
+-- @field status string `"ok"` when the API reports itself healthy
+-- @field dne string identifier of the fortnightly database
+-- @field ceps number how many CEPs the base holds
+-- @field geo number how many of them carry a coordinate
+-- @field base_gerada_em string when the base was generated, RFC 3339
+local Health = {}
+Health.__index = Health
+Health.__name = "cepis.Health"
+
+--- True when the API reports itself healthy.
+-- @treturn boolean
+function Health:ok()
+ return self.status == "ok"
+end
+
+M.Health = Health
+
+--- Build a `Health` from the decoded response.
+-- @tparam table data decoded JSON object
+-- @treturn Health
+function M.new_health(data)
+ return setmetatable({
+ status = text(data.status),
+ dne = text(data.dne),
+ ceps = integer(data.ceps),
+ geo = integer(data.geo),
+ base_gerada_em = text(data.base_gerada_em),
+ raw = data,
+ }, Health)
+end
+
+return M
diff --git a/test/harness.lua b/test/harness.lua
new file mode 100644
index 0000000..b6b3a65
--- /dev/null
+++ b/test/harness.lua
@@ -0,0 +1,205 @@
+--- Assertions, the test registry and control of the fake server.
+--
+-- No rock beyond the ones the library already needs: registering a test is
+-- `test(name, fn)`, a failure is an `error()` out of an assertion, and the
+-- runner exits non-zero if any of them fires.
+
+local http = require("socket.http")
+local ltn12 = require("ltn12")
+local socket = require("socket")
+local json = require("dkjson")
+
+local M = {}
+
+M.tests = {}
+
+--- Register a test. Order of registration is order of execution.
+function M.test(name, fn)
+ M.tests[#M.tests + 1] = { name = name, fn = fn }
+end
+
+local function fail(message, level)
+ error(message, (level or 2) + 1)
+end
+
+local function show(value)
+ if type(value) == "string" then
+ return string.format("%q", value)
+ end
+ return tostring(value)
+end
+
+function M.ok(value, message)
+ if not value then
+ fail(message or ("expected a truthy value, got " .. show(value)))
+ end
+end
+
+function M.falsy(value, message)
+ if value then
+ fail(message or ("expected a falsy value, got " .. show(value)))
+ end
+end
+
+function M.equal(got, want, message)
+ if got ~= want then
+ fail(
+ (message and (message .. ": ") or "")
+ .. "expected "
+ .. show(want)
+ .. ", got "
+ .. show(got)
+ )
+ end
+end
+
+function M.near(got, want, message)
+ if type(got) ~= "number" or math.abs(got - want) > 1e-6 then
+ fail((message and (message .. ": ") or "") .. "expected ~" .. tostring(want) .. ", got " .. show(got))
+ end
+end
+
+function M.is_nil(value, message)
+ if value ~= nil then
+ fail(message or ("expected nil, got " .. show(value)))
+ end
+end
+
+function M.is_false(value, message)
+ if value ~= false then
+ fail(message or ("expected false, got " .. show(value)))
+ end
+end
+
+--- Assert a call failed with a given error kind, and return the error.
+function M.error_kind(value, err, kind)
+ if value ~= nil then
+ fail("expected a failure, got " .. show(value))
+ end
+ if type(err) ~= "table" or err.kind == nil then
+ fail("expected a cepis error, got " .. show(err))
+ end
+ if err.kind ~= kind then
+ fail("expected kind " .. show(kind) .. ", got " .. show(err.kind) .. " (" .. tostring(err.message) .. ")")
+ end
+ return err
+end
+
+--- Assert a call raised through `error()`, for programmer mistakes.
+function M.raises(fn, ...)
+ local ok = pcall(fn, ...)
+ if ok then
+ fail("expected error() to be raised")
+ end
+end
+
+local function request(url, body)
+ local chunks = {}
+ local headers = nil
+ local source = nil
+ if body then
+ source = ltn12.source.string(body)
+ headers = {
+ ["content-length"] = tostring(#body),
+ ["content-type"] = "application/json",
+ }
+ end
+ local ok, code = http.request({
+ url = url,
+ method = body and "POST" or "GET",
+ source = source,
+ headers = headers,
+ sink = ltn12.sink.table(chunks),
+ })
+ if not ok then
+ error("control request to " .. url .. " failed: " .. tostring(code))
+ end
+ return table.concat(chunks)
+end
+
+local Server = {}
+Server.__index = Server
+
+--- Replace the routes and clear the call log.
+function Server:program(routes)
+ request(self.url .. "/__program", json.encode(routes))
+end
+
+--- Every request target the server has seen since the last `program`.
+function Server:calls()
+ return json.decode(request(self.url .. "/__calls")) or {}
+end
+
+--- How many of them began with `prefix`.
+function Server:count(prefix)
+ local total = 0
+ for _, target in ipairs(self:calls()) do
+ if target:sub(1, #prefix) == prefix then
+ total = total + 1
+ end
+ end
+ return total
+end
+
+function Server:stop()
+ pcall(request, self.url .. "/__stop")
+end
+
+--- Spawn the fake server and wait for it to report its port.
+function M.start_server()
+ local directory = (arg[0] or "test/run.lua"):match("^(.*)[/\\]") or "."
+ local portfile = os.tmpname()
+ os.remove(portfile)
+
+ local interpreter = arg[-1] or "lua"
+ os.execute(
+ string.format(
+ "%s %s/server.lua %s >/dev/null 2>&1 &",
+ interpreter,
+ directory,
+ portfile
+ )
+ )
+
+ local port
+ local deadline = socket.gettime() + 10
+ while socket.gettime() < deadline do
+ local handle = io.open(portfile, "r")
+ if handle then
+ local content = handle:read("*a")
+ handle:close()
+ port = tonumber(content)
+ if port then
+ break
+ end
+ end
+ socket.sleep(0.02)
+ end
+
+ os.remove(portfile)
+ if not port then
+ error("the fake server did not start")
+ end
+
+ return setmetatable({ url = "http://127.0.0.1:" .. port, port = port }, Server)
+end
+
+--- Run every registered test, printing one line each.
+function M.run()
+ local failures = 0
+ for _, entry in ipairs(M.tests) do
+ local ok, err = pcall(entry.fn)
+ if ok then
+ print("ok - " .. entry.name)
+ else
+ failures = failures + 1
+ print("FAIL - " .. entry.name)
+ print(" " .. tostring(err))
+ end
+ end
+
+ print(string.format("\n%d test(s), %d failure(s)", #M.tests, failures))
+ return failures
+end
+
+return M
diff --git a/test/run.lua b/test/run.lua
new file mode 100644
index 0000000..6220615
--- /dev/null
+++ b/test/run.lua
@@ -0,0 +1,506 @@
+--- Test suite.
+--
+-- Every test drives a real HTTP server over a real socket, pointed at through
+-- `base_url`. No test touches the network and none stubs LuaSocket: 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.
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local json = require("dkjson")
+local harness = require("test.harness")
+local cepis = require("cepis")
+local csv = require("cepis.csv")
+
+local test = harness.test
+local equal = harness.equal
+local ok = harness.ok
+local near = harness.near
+
+local SE_ADDRESS = {
+ 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",
+}
+
+local CSV_HEADER =
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"
+
+local function address_json(extra)
+ local body = {}
+ for key, value in pairs(SE_ADDRESS) do
+ body[key] = value
+ end
+ for key, value in pairs(extra or {}) do
+ body[key] = value
+ end
+ return json.encode(body)
+end
+
+local function csv_body(rows)
+ return CSV_HEADER .. "\n" .. table.concat(rows, "\n") .. "\n"
+end
+
+local function once(status, body, headers)
+ return { responses = { { status = status, body = body, headers = headers } } }
+end
+
+local server = harness.start_server()
+
+local function client(opts)
+ opts = opts or {}
+ opts.base_url = opts.base_url or server.url
+ if opts.timeout == nil then
+ opts.timeout = 5
+ end
+ if opts.backoff_base == nil then
+ opts.backoff_base = 0.001
+ end
+ if opts.backoff_max == nil then
+ opts.backoff_max = 0.01
+ end
+ return cepis.new(opts)
+end
+
+test("normalize_cep accepts the mask and stray separators", function()
+ for _, value in ipairs({ "01001000", "01001-000", " 01001-000 ", "01001.000" }) do
+ equal(cepis.normalize_cep(value), "01001000", value)
+ end
+end)
+
+test("normalize_cep rejects anything that is not 8 digits", function()
+ for _, value in ipairs({ "", "123", "0100100a", "010010000", "abcdefgh" }) do
+ local got, err = cepis.normalize_cep(value)
+ harness.error_kind(got, err, cepis.INVALID_CEP)
+ end
+end)
+
+test("normalize_cep rejects a non-string", function()
+ local got, err = cepis.normalize_cep(1001000)
+ harness.error_kind(got, err, cepis.INVALID_CEP)
+end)
+
+test("a successful lookup reads the address", function()
+ server:program({ ["/01001000"] = once(200, address_json()) })
+ local address, err = client():lookup("01001-000")
+ ok(address, tostring(err))
+ equal(address.localidade, "São Paulo")
+ equal(address.uf, "SP")
+ equal(address.logradouro, "Praça da Sé")
+ equal(address.cep, "01001000")
+ equal(address:formatted_cep(), "01001-000")
+ harness.is_nil(address.geo)
+end)
+
+test("a coordinate is read when present", function()
+ server:program({
+ ["/01310100"] = once(
+ 200,
+ address_json({
+ geo = { lat = -23.5, lon = -46.6, raio_m = 120, precisao = "logradouro" },
+ })
+ ),
+ })
+ local address, err = client():lookup("01310100")
+ ok(address, tostring(err))
+ ok(address.geo)
+ near(address.geo.lat, -23.5)
+ near(address.geo.lon, -46.6)
+ equal(address.geo.raio_m, 120)
+ equal(address.geo:is_precise(), true)
+end)
+
+test("a municipality coordinate is not precise", function()
+ server:program({
+ ["/01001000"] = once(
+ 200,
+ address_json({
+ geo = { lat = -23.5, lon = -46.6, raio_m = 22000, precisao = "localidade" },
+ })
+ ),
+ })
+ local address = client():lookup("01001000")
+ ok(address.geo)
+ equal(address.geo:is_precise(), false)
+ equal(address.geo.raio_m, 22000)
+end)
+
+test("a malformed geo block does not lose the address", function()
+ server:program({
+ ["/01001000"] = once(200, address_json({ geo = { lat = "not a number" } })),
+ })
+ local address = client():lookup("01001000")
+ harness.is_nil(address.geo)
+ equal(address.localidade, "São Paulo")
+end)
+
+test("a field the API grows is kept in raw", function()
+ server:program({
+ ["/01001000"] = once(200, address_json({ campo_do_futuro = "value" })),
+ })
+ local address = client():lookup("01001000")
+ equal(address.raw.campo_do_futuro, "value")
+ equal(address.localidade, "São Paulo")
+end)
+
+test("a missing CEP is not_found", function()
+ server:program({
+ ["/99999999"] = once(404, '{"erro":"cep_nao_encontrado","mensagem":"gone"}'),
+ })
+ local address, err = client():lookup("99999999")
+ local failure = harness.error_kind(address, err, cepis.NOT_FOUND)
+ equal(failure.cep, "99999999")
+ equal(failure:is_retriable(), false)
+ ok(cepis.is_error(failure))
+end)
+
+test("404 is not retried", function()
+ server:program({ ["/99999999"] = once(404, '{"erro":"cep_nao_encontrado"}') })
+ local address, err = client():lookup("99999999")
+ harness.error_kind(address, err, cepis.NOT_FOUND)
+ equal(server:count("/99999999"), 1)
+end)
+
+test("an invalid CEP never leaves the machine", function()
+ server:program({})
+ local address, err = client():lookup("123")
+ harness.error_kind(address, err, cepis.INVALID_CEP)
+ equal(#server:calls(), 0)
+end)
+
+test("a 400 from the API becomes invalid_cep with its message", function()
+ server:program({
+ ["/00000000"] = once(400, '{"erro":"cep_invalido","mensagem":"rejected by the API"}'),
+ })
+ local address, err = client():lookup("00000000")
+ local failure = harness.error_kind(address, err, cepis.INVALID_CEP)
+ equal(failure.message, "rejected by the API")
+ equal(server:count("/00000000"), 1)
+end)
+
+test("a body that is not JSON is invalid_response", function()
+ server:program({ ["/01001000"] = once(200, "<html>captive portal</html>") })
+ local address, err = client():lookup("01001000")
+ local failure = harness.error_kind(address, err, cepis.INVALID_RESPONSE)
+ equal(failure:is_retriable(), false)
+end)
+
+test("a JSON array is invalid_response, not a blank address", function()
+ server:program({ ["/01001000"] = once(200, "[1,2,3]") })
+ local address, err = client():lookup("01001000")
+ harness.error_kind(address, err, cepis.INVALID_RESPONSE)
+end)
+
+test("a 500 is retried and the next attempt succeeds", function()
+ server:program({
+ ["/01001000"] = {
+ responses = {
+ { status = 500, body = '{"erro":"interno"}' },
+ { status = 200, body = address_json() },
+ },
+ },
+ })
+ local address, err = client():lookup("01001000")
+ ok(address, tostring(err))
+ equal(address.localidade, "São Paulo")
+ equal(server:count("/01001000"), 2)
+end)
+
+test("attempts are exhausted and then it fails", function()
+ server:program({ ["/01001000"] = once(503, '{"erro":"interno"}') })
+ local address, err = client():lookup("01001000")
+ local failure = harness.error_kind(address, err, cepis.SERVER_ERROR)
+ equal(failure.status, 503)
+ equal(failure:is_retriable(), true)
+ equal(server:count("/01001000"), 3)
+end)
+
+test("max_attempts = 1 disables retrying", function()
+ server:program({ ["/01001000"] = once(500, "{}") })
+ local address, err = client({ max_attempts = 1 }):lookup("01001000")
+ harness.error_kind(address, err, cepis.SERVER_ERROR)
+ equal(server:count("/01001000"), 1)
+end)
+
+test("429 becomes rate_limited and exposes Retry-After", function()
+ server:program({ ["/01001000"] = once(429, "{}", { ["Retry-After"] = "7" }) })
+ local address, err = client():lookup("01001000")
+ local failure = harness.error_kind(address, err, cepis.RATE_LIMITED)
+ near(failure.retry_after, 7)
+ equal(failure:is_retriable(), true)
+end)
+
+test("an absurd Retry-After is capped by backoff_max", function()
+ server:program({ ["/01001000"] = once(429, "{}", { ["Retry-After"] = "3600" }) })
+ local started = os.time()
+ local address, err = client():lookup("01001000")
+ harness.error_kind(address, err, cepis.RATE_LIMITED)
+ ok(os.difftime(os.time(), started) < 5, "the call parked for too long")
+end)
+
+test("Retry-After is read in its HTTP-date form too", function()
+ local future = os.date("!%a, %d %b %Y %H:%M:%S GMT", os.time() + 120)
+ local seconds = require("cepis.client").retry_after_seconds(future)
+ ok(seconds, "an HTTP-date Retry-After was not parsed")
+ ok(seconds > 60 and seconds < 180, "expected roughly 120s, got " .. tostring(seconds))
+ harness.is_nil(require("cepis.client").retry_after_seconds("not a date"))
+end)
+
+test("nothing listening is a network_error", function()
+ local offline = cepis.new({
+ base_url = "http://127.0.0.1:1",
+ max_attempts = 2,
+ backoff_base = 0.001,
+ backoff_max = 0.01,
+ timeout = 1,
+ })
+ local address, err = offline:lookup("01001000")
+ local failure = harness.error_kind(address, err, cepis.NETWORK_ERROR)
+ equal(failure:is_retriable(), true)
+end)
+
+test("total_timeout bounds the whole call", function()
+ server:program({ ["/01001000"] = once(500, "{}") })
+ local bounded = client({
+ max_attempts = 50,
+ backoff_base = 0.05,
+ backoff_max = 0.05,
+ total_timeout = 0.3,
+ })
+ local started = os.time()
+ local address, err = bounded:lookup("01001000")
+ harness.error_kind(address, err, cepis.SERVER_ERROR)
+ ok(os.difftime(os.time(), started) < 5, "total_timeout did not cut the call short")
+ ok(server:count("/01001000") < 50, "every attempt ran despite total_timeout")
+end)
+
+test("a batch keeps order and marks the missing entry", function()
+ server:program({
+ ["/csv"] = once(
+ 200,
+ csv_body({
+ "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107",
+ "99999999,,,,,,,,,,,,",
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001",
+ })
+ ),
+ })
+ local result, err = client():lookup_many({ "01001000", "99999999", "20040020" })
+ ok(result, tostring(err))
+ equal(#result, 3)
+ ok(result[1])
+ equal(result[1].localidade, "São Paulo")
+ harness.is_false(result[2])
+ ok(result[3])
+ equal(result[3].uf, "RJ")
+end)
+
+test("a missing CEP is false, not a hole that truncates the list", function()
+ server:program({
+ ["/csv"] = once(
+ 200,
+ csv_body({
+ "99999999,,,,,,,,,,,,",
+ "01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107",
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001",
+ })
+ ),
+ })
+ local result = client():lookup_many({ "99999999", "01001000", "20040020" })
+
+ equal(#result, 3, "a nil at index 1 would have truncated the sequence")
+
+ local seen = 0
+ for _ in ipairs(result) do
+ seen = seen + 1
+ end
+ equal(seen, 3, "ipairs stopped early, so the list had a hole")
+
+ harness.is_false(result[1])
+ ok(result[2])
+ equal(result[2].localidade, "São Paulo")
+ ok(result[3])
+end)
+
+test("a batch carries no coordinate", function()
+ server:program({
+ ["/csv"] = once(
+ 200,
+ csv_body({
+ "01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107",
+ })
+ ),
+ })
+ local result = client():lookup_many({ "01001000" })
+ ok(result[1])
+ harness.is_nil(result[1].geo)
+end)
+
+test("a quoted field containing a comma is parsed", function()
+ server:program({
+ ["/csv"] = once(
+ 200,
+ csv_body({ '01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1' })
+ ),
+ })
+ local result = client():lookup_many({ "01001000" })
+ ok(result[1])
+ equal(result[1].logradouro, "Rua Um, Dois")
+end)
+
+test("an empty batch makes no request", function()
+ server:program({})
+ local result = client():lookup_many({})
+ equal(#result, 0)
+ equal(#server:calls(), 0)
+end)
+
+test("a batch above 500 is chunked", function()
+ server:program({ ["/csv"] = { csv_from_query = true, status = 200 } })
+
+ local ceps = {}
+ for index = 0, 500 do
+ ceps[#ceps + 1] = string.format("%08d", index)
+ end
+
+ local result, err = client():lookup_many(ceps)
+ ok(result, tostring(err))
+ equal(#result, 501)
+ equal(server:count("/csv"), 2)
+ ok(result[1])
+ ok(result[501])
+end)
+
+test("a truncated batch response is padded, never shifted", function()
+ server:program({
+ ["/csv"] = once(
+ 200,
+ csv_body({ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1" })
+ ),
+ })
+ local result = client():lookup_many({ "01001000", "20040020" })
+ equal(#result, 2)
+ ok(result[1])
+ harness.is_false(result[2])
+end)
+
+test("a batch response longer than the request is truncated", function()
+ server:program({
+ ["/csv"] = once(
+ 200,
+ csv_body({
+ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1",
+ "20040020,,,,,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,1,,21,1",
+ })
+ ),
+ })
+ local result = client():lookup_many({ "01001000" })
+ equal(#result, 1)
+end)
+
+test("health reads the live database identifier", function()
+ server:program({
+ ["/health"] = once(
+ 200,
+ json.encode({
+ status = "ok",
+ dne = "26071",
+ ceps = 1596168,
+ geo = 894489,
+ base_gerada_em = "2026-07-24T03:48:28Z",
+ })
+ ),
+ })
+ local health, err = client():health()
+ ok(health, tostring(err))
+ equal(health:ok(), true)
+ equal(health.dne, "26071")
+ equal(health.ceps, 1596168)
+ equal(health.geo, 894489)
+ equal(health.base_gerada_em, "2026-07-24T03:48:28Z")
+end)
+
+test("a degraded API is a diagnosis, not a failure", function()
+ server:program({
+ ["/health"] = once(
+ 503,
+ json.encode({ status = "degradado", dne = "", ceps = 0, geo = 0 })
+ ),
+ })
+ local health, err = client():health()
+ ok(health, tostring(err))
+ equal(health:ok(), false)
+ equal(health.status, "degradado")
+ equal(server:count("/health"), 1)
+end)
+
+test("a trailing slash on base_url is not duplicated", function()
+ equal(cepis.new({ base_url = "http://x/" }).base_url, "http://x")
+ equal(cepis.new({ base_url = "http://x///" }).base_url, "http://x")
+end)
+
+test("a bad option is a programmer mistake and raises", function()
+ harness.raises(cepis.new, { max_attempts = 0 })
+ harness.raises(cepis.new, { timeout = 0 })
+ harness.raises(cepis.new, { timeout = -1 })
+ harness.raises(cepis.new, { base_url = "" })
+ harness.raises(cepis.new, { total_timeout = 0 })
+ harness.raises(function()
+ return cepis.new():lookup_many("not a table")
+ end)
+end)
+
+test("the default User-Agent identifies the library", function()
+ local agent = cepis.new().user_agent
+ ok(agent:find("cepis-lua/", 1, true), agent)
+ ok(agent:find(cepis._VERSION, 1, true), agent)
+ equal(cepis.new({ user_agent = "myapp/1.0" }).user_agent, "myapp/1.0")
+end)
+
+test("the defaults are the documented ones", function()
+ local c = cepis.new()
+ equal(c.base_url, "https://cep.is")
+ equal(c.max_attempts, 3)
+ equal(c.backoff_base, 0.5)
+ equal(c.backoff_max, 8.0)
+ harness.is_nil(c.total_timeout)
+ equal(cepis.MAX_BATCH, 500)
+ equal(cepis._VERSION, "0.1.0")
+end)
+
+test("the CSV reader handles quotes, escapes and CRLF", function()
+ local rows = csv.parse('a,b\r\n"x,1","he said ""hi""" \r\n,,\n')
+ equal(#rows, 3)
+ equal(rows[1][1], "a")
+ equal(rows[1][2], "b")
+ equal(rows[2][1], "x,1")
+ equal(rows[2][2], 'he said "hi" ')
+ equal(#rows[3], 3)
+ equal(rows[3][1], "")
+end)
+
+test("the CSV reader ignores a UTF-8 BOM and blank lines", function()
+ local rows = csv.parse("\239\187\191a,b\n\nc,d\n")
+ equal(#rows, 2)
+ equal(rows[1][1], "a")
+ equal(rows[2][1], "c")
+end)
+
+local failures = harness.run()
+server:stop()
+os.exit(failures == 0 and 0 or 1)
diff --git a/test/server.lua b/test/server.lua
new file mode 100644
index 0000000..a51b8bf
--- /dev/null
+++ b/test/server.lua
@@ -0,0 +1,182 @@
+--- Programmable HTTP server used by the suite, run as a separate process.
+--
+-- Lua has no threads, and the client blocks on its socket, so the server
+-- cannot share this interpreter with the code under test. It is spawned once
+-- per run, binds a free loopback port and writes it to the file named in
+-- `arg[1]`, then answers until it is told to stop or nothing has connected for
+-- a while.
+--
+-- Three control routes drive it from the test process:
+--
+-- POST /__program replace the routes and clear the call log
+-- GET /__calls the request targets seen so far, as a JSON array
+-- GET /__stop shut down
+--
+-- A route maps a path (query string excluded) to `{ responses = { ... } }`.
+-- Each request consumes the next response; the last one repeats once the list
+-- runs out, which is how retrying is tested. A route may instead set
+-- `csv_from_query`, and the server then generates one CSV row per CEP in the
+-- query — that keeps the 501-CEP chunking test from shipping a body through
+-- the control channel.
+
+local socket = require("socket")
+local json = require("dkjson")
+
+local IDLE_TIMEOUT = 60
+
+local REASON = {
+ [200] = "OK",
+ [400] = "Bad Request",
+ [404] = "Not Found",
+ [429] = "Too Many Requests",
+ [500] = "Internal Server Error",
+ [502] = "Bad Gateway",
+ [503] = "Service Unavailable",
+}
+
+local CSV_HEADER =
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"
+
+local routes = {}
+local cursor = {}
+local calls = {}
+local running = true
+
+local function unescape(value)
+ return (value:gsub("%%(%x%x)", function(hex)
+ return string.char(tonumber(hex, 16))
+ end))
+end
+
+local function read_request(client)
+ local line = client:receive("*l")
+ if not line then
+ return nil
+ end
+
+ local method, target = line:match("^(%u+)%s+(%S+)")
+ if not method then
+ return nil
+ end
+
+ local headers = {}
+ while true do
+ local header = client:receive("*l")
+ if not header or header == "" then
+ break
+ end
+ local name, value = header:match("^([^:]+):%s*(.*)$")
+ if name then
+ headers[name:lower()] = value
+ end
+ end
+
+ local body = ""
+ local length = tonumber(headers["content-length"] or "0") or 0
+ if length > 0 then
+ body = client:receive(length) or ""
+ end
+
+ return method, target, body
+end
+
+local function send(client, status, body, headers)
+ local out = {
+ string.format("HTTP/1.1 %d %s\r\n", status, REASON[status] or "Status"),
+ }
+ for name, value in pairs(headers or {}) do
+ out[#out + 1] = name .. ": " .. tostring(value) .. "\r\n"
+ end
+ out[#out + 1] = "Content-Length: " .. #body .. "\r\n"
+ out[#out + 1] = "Connection: close\r\n\r\n"
+ out[#out + 1] = body
+ client:send(table.concat(out))
+end
+
+local function csv_for(target)
+ local raw = target:match("[?&]ceps=([^&]*)") or ""
+ local rows = { CSV_HEADER }
+ for cep in unescape(raw):gmatch("[^,]+") do
+ rows[#rows + 1] = cep .. ",,,,,X,SP,São Paulo,Sudeste,1,,11,1"
+ end
+ return table.concat(rows, "\n") .. "\n"
+end
+
+local function next_response(path)
+ local route = routes[path]
+ if not route then
+ return nil
+ end
+
+ local responses = route.responses
+ if not responses or #responses == 0 then
+ return route
+ end
+
+ local index = cursor[path] or 1
+ if index < #responses then
+ cursor[path] = index + 1
+ end
+ return responses[index]
+end
+
+local function serve(client, target, body)
+ local path = target:match("^([^?]*)")
+
+ if path == "/__program" then
+ routes = json.decode(body) or {}
+ cursor = {}
+ calls = {}
+ send(client, 200, "{}")
+ return
+ end
+
+ if path == "/__calls" then
+ send(client, 200, json.encode(calls))
+ return
+ end
+
+ if path == "/__stop" then
+ running = false
+ send(client, 200, "{}")
+ return
+ end
+
+ calls[#calls + 1] = target
+
+ local response = next_response(path)
+ if not response then
+ send(client, 404, '{"erro":"cep_nao_encontrado","mensagem":"CEP nao existe"}')
+ return
+ end
+
+ if response.csv_from_query then
+ send(client, response.status or 200, csv_for(target), response.headers)
+ return
+ end
+
+ send(client, response.status or 200, response.body or "", response.headers)
+end
+
+local server = assert(socket.bind("127.0.0.1", 0))
+local _, port = server:getsockname()
+server:settimeout(IDLE_TIMEOUT)
+
+local handle = assert(io.open(arg[1], "w"))
+handle:write(tostring(port))
+handle:close()
+
+while running do
+ local client = server:accept()
+ if not client then
+ break
+ end
+ client:settimeout(10)
+ local _, target, body = read_request(client)
+ if target then
+ serve(client, target, body)
+ end
+ client:close()
+end
+
+server:close()