diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:38:46 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:38:46 -0300 |
| commit | 3506a69f15fdf1c1c80acec26e906e6f25eeff14 (patch) | |
| tree | 626ddce348a635e91aa7a5d3f64f39e2ba374841 /src | |
| parent | ef1e5586a47002a6f21f43871b5fcfdb2c60af5d (diff) | |
| download | fortran-3506a69f15fdf1c1c80acec26e906e6f25eeff14.tar.gz | |
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. Answering that with a bare "not
found" throws away what the API already knows: which municipality the
number would have fallen under.
The 404 now carries an optional faixa, and it is exposed the way geo
already is — a derived type behind a logical flag on the address, because
the API omits the block entirely rather than sending null. It stays a
404: found is still false and the status is still CEPIS_ERR_NOT_FOUND.
The band is context for the failure, never an address.
cep_ini and cep_fim are the smallest and largest CEP that actually exist
there, not the bounds the Correios publish — the published start for
Areal is 25845000, exactly the CEP that fails. The pair is not a list
either: 118 CEPs over some 4,900 numbers, which is what ceps is for.
Parsing is deliberately silent about every failure. A body that is not
JSON, or a faixa that is absent, null, not an object or empty, leaves
has_faixa false. Turning any of those into an invalid-response error
would tell the caller to retry a CEP that will never exist.
Diffstat (limited to 'src')
| -rw-r--r-- | src/cepis.f90 | 18 | ||||
| -rw-r--r-- | src/cepis_models.f90 | 144 |
2 files changed, 155 insertions, 7 deletions
diff --git a/src/cepis.f90 b/src/cepis.f90 index 27e2c6d..2c4f687 100644 --- a/src/cepis.f90 +++ b/src/cepis.f90 @@ -43,6 +43,7 @@ module cepis public :: cepis_status_name public :: cepis_geo + public :: cepis_faixa public :: cepis_address public :: cepis_health public :: cepis_client @@ -55,7 +56,7 @@ module cepis public :: cepis_format_cep !> Library version. - character(len=*), parameter, public :: CEPIS_VERSION = '0.1.0' + character(len=*), parameter, public :: CEPIS_VERSION = '0.2.0' !> Default API endpoint. character(len=*), parameter, public :: CEPIS_DEFAULT_BASE_URL = 'https://cep.is' @@ -286,6 +287,12 @@ contains !> server knows something this client does not. !> !> This is the only call that carries `has_geo` and `geo`. + !> + !> A 404 is `CEPIS_ERR_NOT_FOUND` and `address%found` stays `.false.`, but + !> the API may still say which municipality the CEP *would* have belonged + !> to. When it does, `address%has_faixa` is `.true.` and `address%faixa` + !> names it — see `cepis_faixa`. It is context for the failure, never an + !> address: the call still failed and the CEP still does not exist. subroutine client_lookup(self, cep, address, status, message, http_status, retry_after) class(cepis_client), intent(in) :: self character(len=*), intent(in) :: cep @@ -315,6 +322,11 @@ contains if (response%status == 404) then status = CEPIS_ERR_NOT_FOUND message = 'CEP '//digits//' does not exist in the DNE database' + call address_faixa_from_body(response%body, address) + if (address%has_faixa) then + message = message//' — its band belongs to '//address%faixa%localidade + if (len(address%faixa%uf) > 0) message = message//'/'//address%faixa%uf + end if return end if @@ -355,7 +367,9 @@ contains !> **The batch carries no coordinate.** The endpoint behind it is CSV, and !> the CSV has no `geo` column. Every address returned here has !> `has_geo = .false.`, including for CEPs that do have one. Need `geo`? - !> Use `lookup`, one at a time. + !> Use `lookup`, one at a time. The same goes for `has_faixa`: the CSV has + !> no `faixa` column either, so a CEP the batch did not resolve is simply + !> `found = .false.` with nothing said about where it would have been. subroutine client_lookup_many(self, ceps, addresses, status, message, & http_status, retry_after) class(cepis_client), intent(in) :: self diff --git a/src/cepis_models.f90 b/src/cepis_models.f90 index c50d206..c58c1ab 100644 --- a/src/cepis_models.f90 +++ b/src/cepis_models.f90 @@ -12,16 +12,18 @@ !> 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_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 @@ -57,6 +59,51 @@ module cepis_models 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 @@ -64,10 +111,10 @@ module cepis_models !> 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. + !> `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. @@ -92,6 +139,14 @@ module cepis_models 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. @@ -133,6 +188,24 @@ contains 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 @@ -188,6 +261,7 @@ contains address%ddd = '' address%siafi = '' address%has_geo = .false. + address%has_faixa = .false. address%raw = '' end subroutine address_clear @@ -252,6 +326,66 @@ contains 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 |