summaryrefslogtreecommitdiffstats
path: root/cepis
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:33:25 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:33:25 -0300
commit58dbe934a90c1eda8e76cd218a413f01a4d9b11d (patch)
treedeaee88adad3791c8f010db7f7161a95318ed67e /cepis
parent7fd555fcaf4304cb0dff3e8f5661565c3618299e (diff)
downloadlua-main.tar.gz
Carry the municipality of a missing CEP on not_foundHEADv0.2.0main
A CEP that fails is rarely a random number: it is someone who knows the city and guessed the digits, and the classic guess is the -000 that 957 municipalities never received. The API now answers that 404 with an optional faixa block naming where the CEP would have been, so a caller can turn "CEP does not exist" into something the user can act on. It hangs off the error, never off an address: the lookup still failed. Parsing gives up quietly at every step — no block, a block that is not an object, one without a municipality to name — because a broken piece of context must not turn a clean not-found into a parse failure. cep_ini and cep_fim are the CEPs that exist, not the range the Correios publish, and the interval between them is sparse; ceps is what keeps the pair from being read as a list of valid CEPs.
Diffstat (limited to 'cepis')
-rw-r--r--cepis/client.lua42
-rw-r--r--cepis/errors.lua9
-rw-r--r--cepis/models.lua86
3 files changed, 130 insertions, 7 deletions
diff --git a/cepis/client.lua b/cepis/client.lua
index 2a8ae2c..d8a42c9 100644
--- a/cepis/client.lua
+++ b/cepis/client.lua
@@ -24,7 +24,7 @@ local csv = require("cepis.csv")
local M = {}
--- Library version, as it appears in the default `User-Agent`.
-M.VERSION = "0.1.0"
+M.VERSION = "0.2.0"
--- Where the API lives unless `base_url` says otherwise.
M.DEFAULT_URL = "https://cep.is"
@@ -191,6 +191,22 @@ local function error_message(body, fallback)
return fallback
end
+--- The `faixa` of a 404 body, when it carries one.
+--
+-- Every step is allowed to give up and return `nil`: the body may not be JSON
+-- at all (an edge answering instead of the API), may not carry the block (the
+-- API omits it, never nulls it, for a CEP in a band no municipality owns), or
+-- may carry something unusable. None of that may turn a clean not-found into a
+-- parse failure — the answer to the caller is the same 404 either way, only
+-- with less context on it.
+local function faixa_from(body)
+ local ok, data = pcall(json.decode, body or "", 1, nil)
+ if not ok or type(data) ~= "table" then
+ return nil
+ end
+ return models.new_faixa(data.faixa)
+end
+
local function transport(url)
if url:lower():find("^https://") then
local ok, https = pcall(require, "ssl.https")
@@ -430,6 +446,15 @@ end
-- the format already passed local validation, so the server knows something
-- this client does not.
--
+-- A `not_found` may carry `err.faixa`, a `Faixa` naming the municipality the
+-- CEP would have belonged to. It is context for the failure and nothing more —
+-- the lookup still failed, and it is `nil` whenever the API omits the block:
+--
+-- local address, err = client:lookup("25845000")
+-- if not address and err.kind == cepis.NOT_FOUND and err.faixa then
+-- print("did you mean a CEP in " .. err.faixa:label() .. "?")
+-- end
+--
-- @tparam string cep masked or unmasked
-- @treturn[1] Address
-- @treturn[2] nil
@@ -448,12 +473,17 @@ function Client:lookup(cep)
end
if status == 404 then
+ local faixa = faixa_from(body)
+ local message = "CEP " .. number .. " does not exist in the DNE database"
+ if faixa then
+ message = message .. ", which would place it in " .. faixa:label()
+ end
return nil,
- errors.new(
- errors.NOT_FOUND,
- "CEP " .. number .. " does not exist in the DNE database",
- { cep = number, status = 404 }
- )
+ errors.new(errors.NOT_FOUND, message, {
+ cep = number,
+ status = 404,
+ faixa = faixa,
+ })
end
if status == 400 then
return nil,
diff --git a/cepis/errors.lua b/cepis/errors.lua
index 0076b95..011014b 100644
--- a/cepis/errors.lua
+++ b/cepis/errors.lua
@@ -27,6 +27,12 @@ local M = {}
M.INVALID_CEP = "invalid_cep"
--- The CEP is well-formed but does not exist in the DNE database.
+--
+-- The error may carry `faixa`, a `cepis.models.Faixa` naming the municipality
+-- that CEP would have belonged to. It is context for the failure, never an
+-- address: the lookup failed all the same. It is `nil` when the CEP falls in a
+-- band no municipality owns — the API omits the block there rather than
+-- sending null.
M.NOT_FOUND = "not_found"
--- The API (or the edge in front of it) answered 429 on every attempt.
@@ -88,7 +94,8 @@ M.Error = Error
--
-- @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`
+-- @tparam[opt] table fields extra context: `cep`, `status`, `retry_after`,
+-- `faixa`
-- @treturn table the error, with `tostring()` yielding `message`
function M.new(kind, message, fields)
local err = setmetatable({}, Error)
diff --git a/cepis/models.lua b/cepis/models.lua
index 61ad678..a433369 100644
--- a/cepis/models.lua
+++ b/cepis/models.lua
@@ -185,6 +185,92 @@ function M.new_address(data)
return address
end
+--- The municipality a CEP that does not exist would have belonged to.
+--
+-- This is **context for an error, never an address**. It arrives on the 404 of
+-- `Client:lookup` and hangs off the `not_found` error as `err.faixa`; the
+-- lookup still failed, and nothing here says otherwise. The CEP asked for does
+-- not exist. What the API is saying is only *where it would have been*.
+--
+-- It exists because a CEP that fails is rarely a random number: it is usually
+-- someone who knows the city and guessed the digits, and the classic guess is
+-- the `-000` that 957 municipalities never received. Knowing the guess landed
+-- on Areal/RJ turns "CEP does not exist" into a message the user can act on.
+--
+-- **`cep_ini` and `cep_fim` are the smallest and the largest CEP that actually
+-- exist in that municipality — not the bounds the Correios publish.** For
+-- Areal the DNE's own range starts at `25845000`, which is precisely the CEP
+-- that just failed; the range reported here starts at `25845001`, the first
+-- one really in the base.
+--
+-- **The range is sparse.** Areal's 118 CEPs are spread over the roughly 4,900
+-- numbers between `25845001` and `25849899`. That is what `ceps` is for: the
+-- pair is an interval, never a list of valid CEPs, and picking a number
+-- between them has about one chance in forty of existing.
+--
+-- @type Faixa
+-- @field localidade string the municipality
+-- @field uf string
+-- @field estado string
+-- @field regiao string
+-- @field ibge string
+-- @field cep_ini string smallest CEP that exists there, 8 digits
+-- @field cep_fim string largest CEP that exists there, 8 digits
+-- @field ceps number how many exist between the two — far fewer than the span
+local Faixa = {}
+Faixa.__index = Faixa
+Faixa.__name = "cepis.Faixa"
+
+--- The municipality and its UF: `Areal/RJ`.
+--
+-- The one-line form for a message shown to a person, so it does not become a
+-- repeated concatenation in every project that uses this.
+-- @treturn string
+function Faixa:label()
+ if self.uf == "" then
+ return self.localidade
+ end
+ return self.localidade .. "/" .. self.uf
+end
+
+M.Faixa = Faixa
+
+--- Build a `Faixa` from the `faixa` block of a 404 body, or return `nil`.
+--
+-- The API **omits** the block, never sends it as null, when the CEP falls in a
+-- band no municipality owns. So `nil` here is the ordinary case, not a
+-- failure.
+--
+-- Anything unusable — absent, not an object, without a municipality to name —
+-- is also `nil`. That is deliberate and it is the whole point of this
+-- function: the caller is already holding a not-found, and a malformed piece
+-- of context must never be allowed to turn that clean answer into a parse
+-- error.
+--
+-- @tparam[opt] table block the decoded `faixa` object
+-- @treturn Faixa|nil
+function M.new_faixa(block)
+ if type(block) ~= "table" then
+ return nil
+ end
+
+ local localidade = text(block.localidade)
+ if localidade == "" then
+ return nil
+ end
+
+ return setmetatable({
+ localidade = localidade,
+ uf = text(block.uf),
+ estado = text(block.estado),
+ regiao = text(block.regiao),
+ ibge = text(block.ibge),
+ cep_ini = text(block.cep_ini),
+ cep_fim = text(block.cep_fim),
+ ceps = integer(block.ceps),
+ }, Faixa)
+end
+
--- The response of `GET /health`.
--
-- Good for two things: checking that the API answers, and knowing **which