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