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
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
|
!> The data the API returns, as derived types.
!>
!> **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 access on the derived type.
!> 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
use, intrinsic :: iso_fortran_env, only: real64, int64
use cepis_json, only: json_document, json_parse, JSON_NUMBER, JSON_STRING, JSON_OBJECT
use cepis_strings, only: parse_real, is_digits
implicit none
private
public :: cepis_geo
public :: cepis_faixa
public :: cepis_address
public :: cepis_health
public :: address_clear
public :: address_from_json
public :: address_faixa_from_body
public :: health_from_json
public :: cepis_format_cep
!> 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 :: cepis_geo
!> Latitude in decimal degrees.
real(real64) :: lat = 0.0_real64
!> Longitude in decimal degrees.
real(real64) :: lon = 0.0_real64
!> Radius in metres containing 90% of the CEP's addresses.
integer :: raio_m = 0
!> `"logradouro"` or `"localidade"`.
character(len=:), allocatable :: precisao
contains
!> `.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.
procedure :: is_precise
end type cepis_geo
!> The municipality a CEP that does **not** exist would have belonged to.
!>
!> This is **context for an error, not an address**. It arrives with a 404
!> and it leaves the lookup a 404: nothing here says the CEP exists, and
!> nothing here may be delivered 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. `faixa` is what lets you answer *"25845-000 is not a CEP; did
!> you mean one in Areal/RJ?"* instead of a bare "not found".
!>
!> `cep_ini` and `cep_fim` are the smallest and the largest CEP that
!> **actually exist** in that municipality, which is not what the Correios
!> publish as the band's bounds. The DNE's own start for Areal is
!> `25845000` — precisely the CEP that just failed, which is why the
!> published bound would be useless here.
!>
!> **The range is sparse, and the pair is not a list.** Areal's 118 CEPs are
!> scattered over the roughly 4,900 numbers between `25845001` and
!> `25849899`; the overwhelming majority of the numbers in between do not
!> exist either. `ceps` is there to make that impossible to miss. Never
!> enumerate `cep_ini`..`cep_fim` and never offer it as a set of valid CEPs;
!> use it to show a range, and validate any single number with a lookup.
type :: cepis_faixa
!> Municipality name, e.g. `Areal`.
character(len=:), allocatable :: localidade
!> Two-letter state code, e.g. `RJ`.
character(len=:), allocatable :: uf
character(len=:), allocatable :: estado
character(len=:), allocatable :: regiao
!> IBGE municipality code.
character(len=:), allocatable :: ibge
!> Smallest CEP that exists in the municipality, 8 digits, unmasked.
character(len=:), allocatable :: cep_ini
!> Largest CEP that exists in the municipality, 8 digits, unmasked.
character(len=:), allocatable :: cep_fim
!> How many CEPs exist between `cep_ini` and `cep_fim`, inclusive.
!> Far fewer than the span between them: the range is sparse.
integer :: ceps = 0
contains
!> `cep_ini` with its mask: `25845-001`. Empty when it is not 8 digits.
procedure :: formatted_cep_ini
!> `cep_fim` with its mask: `25849-899`. Empty when it is not 8 digits.
procedure :: formatted_cep_fim
end type cepis_faixa
!> A CEP and everything the database knows about it.
!>
!> Every character component is allocated after any successful call. Fields
!> the database does not have for that CEP are the empty string, never
!> unallocated — that is what the API returns, and changing it here would
!> create a difference between reading the JSON and reading this library.
!>
!> `geo` and `faixa` are the exceptions, and they are guarded by `has_geo`
!> and `has_faixa` rather than by an unallocated component: the API
!> **omits** either field entirely — it never sends null — and a flag makes
!> the check impossible to forget.
type :: cepis_address
!> `.false.` for a CEP that the batch did not resolve. Always `.true.`
!> for an address that came back from a single lookup.
logical :: found = .false.
!> The 8 digits, unmasked.
character(len=:), allocatable :: cep
character(len=:), allocatable :: logradouro
character(len=:), allocatable :: complemento
character(len=:), allocatable :: unidade
character(len=:), allocatable :: bairro
character(len=:), allocatable :: localidade
!> Two-letter state code, e.g. `SP`.
character(len=:), allocatable :: uf
character(len=:), allocatable :: estado
character(len=:), allocatable :: regiao
!> IBGE municipality code.
character(len=:), allocatable :: ibge
character(len=:), allocatable :: gia
character(len=:), allocatable :: ddd
character(len=:), allocatable :: siafi
!> `.true.` when `geo` carries a coordinate.
logical :: has_geo = .false.
!> The coordinate. Meaningful only when `has_geo` is `.true.`.
type(cepis_geo) :: geo
!> `.true.` when `faixa` names the municipality this CEP would have
!> belonged to. Only ever set on a `CEPIS_ERR_NOT_FOUND`, and never on
!> a CEP that was found — it is the API's context for the 404.
logical :: has_faixa = .false.
!> The municipality the failing CEP falls under. Meaningful only when
!> `has_faixa` is `.true.`, and **not** an address: `found` is still
!> `.false.` and the lookup still failed.
type(cepis_faixa) :: faixa
!> The response body exactly as it arrived, uninterpreted, so a field
!> the API grows before this library is updated is not lost. Empty for
!> addresses that came from a batch, whose transport is CSV.
character(len=:), allocatable :: raw
contains
!> The CEP with its mask: `01001-000`. Empty when `cep` is not 8
!> digits.
procedure :: formatted_cep
end type cepis_address
!> 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 :: cepis_health
!> `ok` when the API reports itself healthy.
character(len=:), allocatable :: status
!> Identifier of the fortnightly DNE database.
character(len=:), allocatable :: dne
!> CEPs in the database.
integer(int64) :: ceps = 0_int64
!> CEPs that carry a coordinate.
integer(int64) :: geo = 0_int64
!> ISO-8601 timestamp of the database build.
character(len=:), allocatable :: base_gerada_em
contains
!> `.true.` when the API reports itself healthy.
procedure :: ok => health_ok
end type cepis_health
contains
pure function is_precise(self) result(yes)
class(cepis_geo), intent(in) :: self
logical :: yes
yes = .false.
if (allocated(self%precisao)) yes = self%precisao == 'logradouro'
end function is_precise
pure function formatted_cep_ini(self) result(text)
class(cepis_faixa), intent(in) :: self
character(len=:), allocatable :: text
text = ''
if (.not. allocated(self%cep_ini)) return
text = cepis_format_cep(self%cep_ini)
end function formatted_cep_ini
pure function formatted_cep_fim(self) result(text)
class(cepis_faixa), intent(in) :: self
character(len=:), allocatable :: text
text = ''
if (.not. allocated(self%cep_fim)) return
text = cepis_format_cep(self%cep_fim)
end function formatted_cep_fim
pure function health_ok(self) result(yes)
class(cepis_health), intent(in) :: self
logical :: yes
yes = .false.
if (allocated(self%status)) yes = self%status == 'ok'
end function health_ok
pure function formatted_cep(self) result(text)
class(cepis_address), intent(in) :: self
character(len=:), allocatable :: text
text = ''
if (.not. allocated(self%cep)) return
text = cepis_format_cep(self%cep)
end function formatted_cep
!> Write 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
!> `write` in every project that uses this. Empty for anything that is not
!> 8 digits.
pure function cepis_format_cep(cep) result(text)
character(len=*), intent(in) :: cep
character(len=:), allocatable :: text
text = ''
if (len(cep) /= 8) return
if (.not. is_digits(cep)) return
text = cep(1:5)//'-'//cep(6:8)
end function cepis_format_cep
!> Reset an address to empty strings and no coordinate.
!>
!> Every character component ends up allocated, so a caller can read any of
!> them without checking `allocated` first.
pure subroutine address_clear(address)
type(cepis_address), intent(out) :: address
address%found = .false.
address%cep = ''
address%logradouro = ''
address%complemento = ''
address%unidade = ''
address%bairro = ''
address%localidade = ''
address%uf = ''
address%estado = ''
address%regiao = ''
address%ibge = ''
address%gia = ''
address%ddd = ''
address%siafi = ''
address%has_geo = .false.
address%has_faixa = .false.
address%raw = ''
end subroutine address_clear
!> Build an address from a parsed JSON object.
!>
!> Unknown fields are ignored but preserved in `raw`, so a new field in the
!> API cannot break this library. A malformed `geo` block yields
!> `has_geo = .false.` rather than a failure: it is not worth losing the
!> whole address over, since the rest of the response is still useful.
subroutine address_from_json(doc, node, raw, address)
type(json_document), intent(in) :: doc
integer, intent(in) :: node
character(len=*), intent(in) :: raw
type(cepis_address), intent(out) :: address
call address_clear(address)
address%found = .true.
address%raw = raw
address%cep = doc%field_text(node, 'cep')
address%logradouro = doc%field_text(node, 'logradouro')
address%complemento = doc%field_text(node, 'complemento')
address%unidade = doc%field_text(node, 'unidade')
address%bairro = doc%field_text(node, 'bairro')
address%localidade = doc%field_text(node, 'localidade')
address%uf = doc%field_text(node, 'uf')
address%estado = doc%field_text(node, 'estado')
address%regiao = doc%field_text(node, 'regiao')
address%ibge = doc%field_text(node, 'ibge')
address%gia = doc%field_text(node, 'gia')
address%ddd = doc%field_text(node, 'ddd')
address%siafi = doc%field_text(node, 'siafi')
call read_geo(doc, doc%member(node, 'geo'), address)
end subroutine address_from_json
subroutine read_geo(doc, node, address)
type(json_document), intent(in) :: doc
integer, intent(in) :: node
type(cepis_address), intent(inout) :: address
real(real64) :: lat, lon, radius
logical :: ok
character(len=:), allocatable :: precisao
address%has_geo = .false.
if (doc%kind_of(node) /= JSON_OBJECT) return
call number_field(doc, node, 'lat', lat, ok)
if (.not. ok) return
call number_field(doc, node, 'lon', lon, ok)
if (.not. ok) return
call number_field(doc, node, 'raio_m', radius, ok)
if (.not. ok) return
if (doc%kind_of(doc%member(node, 'precisao')) /= JSON_STRING) return
precisao = doc%field_text(node, 'precisao')
address%geo%lat = lat
address%geo%lon = lon
address%geo%raio_m = int(radius)
address%geo%precisao = precisao
address%has_geo = .true.
end subroutine read_geo
!> Read the `faixa` block out of the body of a 404, if it has one.
!>
!> Takes the raw body rather than a parsed document because the caller has
!> not parsed it: a not-found is decided by the HTTP status, and the body
!> of an error is never required to be intelligible.
!>
!> **Every failure here is silent** and leaves `has_faixa = .false.` — a
!> body that is not JSON, a `faixa` that is absent, null, not an object, or
!> empty. Turning any of those into a parse error would replace a clean
!> `CEPIS_ERR_NOT_FOUND` with `CEPIS_ERR_INVALID_RESPONSE` and tell the
!> caller to retry a CEP that will never exist. The 404 is the answer; the
!> `faixa` is a bonus, and a bonus may not cost the answer.
subroutine address_faixa_from_body(body, address)
character(len=*), intent(in) :: body
type(cepis_address), intent(inout) :: address
type(json_document) :: doc
character(len=:), allocatable :: detail
logical :: ok
address%has_faixa = .false.
call json_parse(body, doc, ok, detail)
if (.not. ok) return
if (doc%kind_of(doc%root) /= JSON_OBJECT) return
call read_faixa(doc, doc%member(doc%root, 'faixa'), address)
end subroutine address_faixa_from_body
subroutine read_faixa(doc, node, address)
type(json_document), intent(in) :: doc
integer, intent(in) :: node
type(cepis_address), intent(inout) :: address
character(len=:), allocatable :: localidade
real(real64) :: count
logical :: ok
address%has_faixa = .false.
if (doc%kind_of(node) /= JSON_OBJECT) return
! The municipality name is the whole point of the block: without it
! there is nothing to tell the caller, so an empty one is no faixa.
localidade = doc%field_text(node, 'localidade')
if (len(localidade) == 0) return
address%faixa%localidade = localidade
address%faixa%uf = doc%field_text(node, 'uf')
address%faixa%estado = doc%field_text(node, 'estado')
address%faixa%regiao = doc%field_text(node, 'regiao')
address%faixa%ibge = doc%field_text(node, 'ibge')
address%faixa%cep_ini = doc%field_text(node, 'cep_ini')
address%faixa%cep_fim = doc%field_text(node, 'cep_fim')
! A missing count leaves zero rather than dropping the block: the name
! of the municipality is still worth having.
address%faixa%ceps = 0
call number_field(doc, node, 'ceps', count, ok)
if (ok) address%faixa%ceps = int(count)
address%has_faixa = .true.
end subroutine read_faixa
subroutine number_field(doc, node, name, value, ok)
type(json_document), intent(in) :: doc
integer, intent(in) :: node
character(len=*), intent(in) :: name
real(real64), intent(out) :: value
logical, intent(out) :: ok
integer :: child
value = 0.0_real64
ok = .false.
child = doc%member(node, name)
select case (doc%kind_of(child))
case (JSON_NUMBER)
value = doc%number_of(child)
ok = .true.
case (JSON_STRING)
call parse_real(doc%text_of(child), value, ok)
end select
end subroutine number_field
!> Build a health report from a parsed JSON object.
subroutine health_from_json(doc, node, health)
type(json_document), intent(in) :: doc
integer, intent(in) :: node
type(cepis_health), intent(out) :: health
health%status = doc%field_text(node, 'status')
health%dne = doc%field_text(node, 'dne')
health%ceps = doc%field_integer(node, 'ceps')
health%geo = doc%field_integer(node, 'geo')
health%base_gerada_em = doc%field_text(node, 'base_gerada_em')
end subroutine health_from_json
end module cepis_models
|