1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
|
--- 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
|