!> 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