summaryrefslogtreecommitdiffstats
path: root/cepis/client.lua
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/client.lua
downloadlua-7fd555fcaf4304cb0dff3e8f5661565c3618299e.tar.gz
Initial importv0.1.0
Diffstat (limited to 'cepis/client.lua')
-rw-r--r--cepis/client.lua609
1 files changed, 609 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