diff options
Diffstat (limited to 'cepis/client.lua')
| -rw-r--r-- | cepis/client.lua | 609 |
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 |