From 7fd555fcaf4304cb0dff3e8f5661565c3618299e Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 12:42:08 -0300 Subject: Initial import --- cepis/client.lua | 609 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ cepis/csv.lua | 109 ++++++++++ cepis/errors.lua | 115 +++++++++++ cepis/models.lua | 226 +++++++++++++++++++++ 4 files changed, 1059 insertions(+) create mode 100644 cepis/client.lua create mode 100644 cepis/csv.lua create mode 100644 cepis/errors.lua create mode 100644 cepis/models.lua (limited to 'cepis') 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 -- cgit v1.3