summaryrefslogtreecommitdiffstats
path: root/src/cepis_models.f90
diff options
context:
space:
mode:
Diffstat (limited to 'src/cepis_models.f90')
-rw-r--r--src/cepis_models.f90289
1 files changed, 289 insertions, 0 deletions
diff --git a/src/cepis_models.f90 b/src/cepis_models.f90
new file mode 100644
index 0000000..c50d206
--- /dev/null
+++ b/src/cepis_models.f90
@@ -0,0 +1,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