diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:52:43 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:52:43 -0300 |
| commit | 42d8c12d2bd88462df8c3aac9449abbf98400084 (patch) | |
| tree | 564b07e68aed22c3aa4a066b822d731328fb4e97 /src/cepis_models.f90 | |
| download | fortran-42d8c12d2bd88462df8c3aac9449abbf98400084.tar.gz | |
Initial import
Diffstat (limited to 'src/cepis_models.f90')
| -rw-r--r-- | src/cepis_models.f90 | 289 |
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 |