summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md51
-rw-r--r--cepis-0.2.0-1.rockspec (renamed from cepis-0.1.0-1.rockspec)4
-rw-r--r--cepis.lua5
-rw-r--r--cepis/client.lua42
-rw-r--r--cepis/errors.lua9
-rw-r--r--cepis/models.lua86
-rw-r--r--test/run.lua94
7 files changed, 278 insertions, 13 deletions
diff --git a/README.md b/README.md
index ed4988c..67cd2ba 100644
--- a/README.md
+++ b/README.md
@@ -20,9 +20,9 @@ luarocks install cepis
Targets **Lua 5.4**. It also runs unmodified on 5.1, 5.2, 5.3 and LuaJIT —
nothing here needs anything newer, so the compatibility was free.
-> The rockspec points at `git+https://git.cep.is/lua.git` with tag `v0.1.0`.
+> The rockspec points at `git+https://git.cep.is/lua.git` with tag `v0.2.0`.
> That repository is not published yet, so `luarocks install cepis` will not
-> resolve until it is. Until then, `luarocks make cepis-0.1.0-1.rockspec` from
+> resolve until it is. Until then, `luarocks make cepis-0.2.0-1.rockspec` from
> a clone works.
Three rocks are needed, because Lua's standard library ships neither an HTTP
@@ -198,7 +198,8 @@ Every expected failure is returned as `nil, err`. `error()` is raised only for
programmer mistakes — a bad option table, a wrong argument type.
An error is a table carrying `kind`, `message`, and whichever of `cep`,
-`status` and `retry_after` apply. `tostring(err)` yields the message. The kinds
+`status`, `retry_after` and `faixa` apply. `tostring(err)` yields the message.
+The kinds
split by **the decision you have to make**, not by the HTTP status that caused
it:
@@ -231,6 +232,50 @@ else a `nil, err` boundary might hand you.
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.
+### Where a missing CEP would have been
+
+A `not_found` may carry `err.faixa`, naming the municipality the CEP would have
+belonged to. 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.
+
+```lua
+local address, err = cepis.lookup("25845-000")
+
+err.kind -- "not_found" it still does not exist
+err.faixa:label() -- "Areal/RJ"
+err.faixa.localidade -- "Areal"
+err.faixa.uf -- "RJ"
+err.faixa.ibge -- "3300225"
+err.faixa.cep_ini -- "25845001"
+err.faixa.cep_fim -- "25849899"
+err.faixa.ceps -- 118
+```
+
+**This is context for an error, not an address.** The lookup failed; `faixa`
+only says where the CEP would have been. Never treat it as a successful
+result.
+
+**`cep_ini` and `cep_fim` are the smallest and largest CEP that actually
+exist** in that municipality — not the range the Correios publish. For Areal
+the published range starts at `25845000`, which is exactly the CEP that just
+failed.
+
+**The range is sparse.** Areal's 118 CEPs are spread over the roughly 4,900
+numbers between `25845001` and `25849899`, so the pair is an interval, never a
+list of valid CEPs — that is what `ceps` is there to say.
+
+`err.faixa` is `nil` whenever the CEP falls in a band no municipality owns: the
+API omits the field there rather than sending null. It is also `nil` if the
+block arrives malformed — a broken piece of context must not turn a clean
+not-found into a parse failure, so always test it before use:
+
+```lua
+if err.kind == cepis.NOT_FOUND and err.faixa then
+ print("no such CEP — did you mean one in " .. err.faixa:label() .. "?")
+end
+```
+
## Development
Everything runs through `make`:
diff --git a/cepis-0.1.0-1.rockspec b/cepis-0.2.0-1.rockspec
index c303f96..5a3b283 100644
--- a/cepis-0.1.0-1.rockspec
+++ b/cepis-0.2.0-1.rockspec
@@ -1,10 +1,10 @@
rockspec_format = "3.0"
package = "cepis"
-version = "0.1.0-1"
+version = "0.2.0-1"
source = {
url = "git+https://git.cep.is/lua.git",
- tag = "v0.1.0",
+ tag = "v0.2.0",
}
description = {
diff --git a/cepis.lua b/cepis.lua
index e120af1..1027db3 100644
--- a/cepis.lua
+++ b/cepis.lua
@@ -65,6 +65,9 @@ M.Address = models.Address
--- The `Geo` metatable.
M.Geo = models.Geo
+--- The `Faixa` metatable, for the `faixa` a `not_found` error may carry.
+M.Faixa = models.Faixa
+
--- The `Health` metatable.
M.Health = models.Health
@@ -72,6 +75,8 @@ M.Health = models.Health
M.INVALID_CEP = errors.INVALID_CEP
--- Error kind: well-formed, but absent from the DNE database.
+--
+-- `err.faixa` may name the municipality the CEP would have belonged to.
M.NOT_FOUND = errors.NOT_FOUND
--- Error kind: 429 on every attempt. `err.retry_after` may hold seconds.
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
diff --git a/test/run.lua b/test/run.lua
index 6220615..3b646c7 100644
--- a/test/run.lua
+++ b/test/run.lua
@@ -38,6 +38,28 @@ local SE_ADDRESS = {
siafi = "7107",
}
+-- The 404 body of a CEP that does not exist, optionally carrying the `faixa`
+-- block. `nil` is encoded as the field being absent, which is what the API
+-- does — it omits the block, it never sends null.
+local AREAL_FAIXA = {
+ localidade = "Areal",
+ uf = "RJ",
+ estado = "Rio de Janeiro",
+ regiao = "Sudeste",
+ ibge = "3300225",
+ cep_ini = "25845001",
+ cep_fim = "25849899",
+ ceps = 118,
+}
+
+local function not_found_json(faixa)
+ return json.encode({
+ erro = "cep_nao_encontrado",
+ mensagem = "CEP não existe na base do DNE",
+ faixa = faixa,
+ })
+end
+
local CSV_HEADER =
"cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"
@@ -176,6 +198,76 @@ test("404 is not retried", function()
equal(server:count("/99999999"), 1)
end)
+test("a missing CEP names the municipality it would have been in", function()
+ -- 25845000 is the DNE's own start for Areal and does not exist: the classic
+ -- guess, the -000 that 957 municipalities never received.
+ server:program({ ["/25845000"] = once(404, not_found_json(AREAL_FAIXA)) })
+ local address, err = client():lookup("25845000")
+
+ local failure = harness.error_kind(address, err, cepis.NOT_FOUND)
+ equal(failure.cep, "25845000")
+ equal(failure:is_retriable(), false)
+
+ local faixa = failure.faixa
+ ok(faixa, "the faixa was not read")
+ equal(getmetatable(faixa), cepis.Faixa)
+ equal(faixa.localidade, "Areal")
+ equal(faixa.uf, "RJ")
+ equal(faixa.estado, "Rio de Janeiro")
+ equal(faixa.regiao, "Sudeste")
+ equal(faixa.ibge, "3300225")
+ equal(faixa:label(), "Areal/RJ")
+
+ -- The bounds are the CEPs that exist, not the ones the Correios publish:
+ -- the published range starts at the 25845000 that just failed.
+ equal(faixa.cep_ini, "25845001")
+ equal(faixa.cep_fim, "25849899")
+
+ -- And the range is sparse — 118 CEPs over some 4,900 numbers. `ceps` is
+ -- what keeps the pair from being read as a list of valid CEPs.
+ equal(faixa.ceps, 118)
+ ok(faixa.ceps < tonumber(faixa.cep_fim) - tonumber(faixa.cep_ini))
+
+ ok(failure.message:find("Areal/RJ", 1, true), failure.message)
+end)
+
+test("a not_found is not turned into an address by its faixa", function()
+ server:program({ ["/25845000"] = once(404, not_found_json(AREAL_FAIXA)) })
+ local address, err = client():lookup("25845000")
+ harness.error_kind(address, err, cepis.NOT_FOUND)
+ harness.is_nil(address, "a 404 with a faixa returned something")
+ equal(server:count("/25845000"), 1, "a 404 with a faixa was retried")
+end)
+
+test("a 404 without a faixa is a not_found all the same", function()
+ server:program({ ["/99999999"] = once(404, not_found_json(nil)) })
+ local address, err = client():lookup("99999999")
+ local failure = harness.error_kind(address, err, cepis.NOT_FOUND)
+ harness.is_nil(failure.faixa)
+ equal(failure.message, "CEP 99999999 does not exist in the DNE database")
+end)
+
+test("an unusable faixa yields nil, never a parse failure", function()
+ local bodies = {
+ ['{"erro":"cep_nao_encontrado","faixa":null}'] = "null",
+ ['{"erro":"cep_nao_encontrado","faixa":{}}'] = "empty object",
+ ['{"erro":"cep_nao_encontrado","faixa":"Areal"}'] = "a string",
+ ['{"erro":"cep_nao_encontrado","faixa":[1,2]}'] = "an array",
+ ['{"erro":"cep_nao_encontrado","faixa":{"uf":"RJ"}}'] = "no localidade",
+ ['{"erro":"cep_nao_encontrado","faixa":{"localidade":""}}'] = "empty localidade",
+ ["<html>captive portal</html>"] = "not JSON at all",
+ [""] = "an empty body",
+ }
+
+ for body, what in pairs(bodies) do
+ server:program({ ["/99999999"] = once(404, body) })
+ local address, err = client():lookup("99999999")
+ local failure = harness.error_kind(address, err, cepis.NOT_FOUND)
+ harness.is_nil(failure.faixa, "expected no faixa for " .. what)
+ equal(failure.cep, "99999999", what)
+ end
+end)
+
test("an invalid CEP never leaves the machine", function()
server:program({})
local address, err = client():lookup("123")
@@ -480,7 +572,7 @@ test("the defaults are the documented ones", function()
equal(c.backoff_max, 8.0)
harness.is_nil(c.total_timeout)
equal(cepis.MAX_BATCH, 500)
- equal(cepis._VERSION, "0.1.0")
+ equal(cepis._VERSION, "0.2.0")
end)
test("the CSV reader handles quotes, escapes and CRLF", function()