--- 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 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 -- 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