summaryrefslogtreecommitdiffstats
path: root/cepis/models.lua
diff options
context:
space:
mode:
Diffstat (limited to 'cepis/models.lua')
-rw-r--r--cepis/models.lua226
1 files changed, 226 insertions, 0 deletions
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