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
|
!> 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_NUMBER, JSON_STRING, JSON_OBJECT
use cepis_strings, only: parse_real, is_digits
implicit none
private
public :: cepis_geo
public :: cepis_address
public :: cepis_health
public :: address_clear
public :: address_from_json
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
!> 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` is the exception, and it is guarded by `has_geo` rather than by an
!> unallocated component: the API **omits** the whole field when there is
!> no coordinate (about 43% of CEPs) rather than sending 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
!> 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 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%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
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
|