diff options
| -rw-r--r-- | README.md | 51 | ||||
| -rw-r--r-- | cepis-0.2.0-1.rockspec (renamed from cepis-0.1.0-1.rockspec) | 4 | ||||
| -rw-r--r-- | cepis.lua | 5 | ||||
| -rw-r--r-- | cepis/client.lua | 42 | ||||
| -rw-r--r-- | cepis/errors.lua | 9 | ||||
| -rw-r--r-- | cepis/models.lua | 86 | ||||
| -rw-r--r-- | test/run.lua | 94 |
7 files changed, 278 insertions, 13 deletions
@@ -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 = { @@ -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() |