summaryrefslogtreecommitdiffstats
path: root/cepis
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 /cepis
downloadlua-7fd555fcaf4304cb0dff3e8f5661565c3618299e.tar.gz
Initial importv0.1.0
Diffstat (limited to 'cepis')
-rw-r--r--cepis/client.lua609
-rw-r--r--cepis/csv.lua109
-rw-r--r--cepis/errors.lua115
-rw-r--r--cepis/models.lua226
4 files changed, 1059 insertions, 0 deletions
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