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 | |
| 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.
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | README.md | 49 | ||||
| -rw-r--r-- | app/main.f90 | 14 | ||||
| -rw-r--r-- | src/cepis.f90 | 18 | ||||
| -rw-r--r-- | src/cepis_models.f90 | 144 | ||||
| -rw-r--r-- | test/test_cepis.f90 | 106 |
6 files changed, 323 insertions, 10 deletions
@@ -1,4 +1,4 @@ -VERSION := 0.1.0 +VERSION := 0.2.0 ifeq ($(origin FC),default) FC := gfortran @@ -252,6 +252,55 @@ end select `cepis_status_name(status)` gives a short, stable English name for logging. +### When the CEP does not exist + +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. So a 404 may carry a `faixa` naming the +municipality the CEP *would* have belonged to. It is on the address as +`has_faixa` / `faixa`, exactly like `has_geo` / `geo`: + +```fortran +call cepis_lookup('25845-000', a, status, message) + +if (status == CEPIS_ERR_NOT_FOUND .and. a%has_faixa) then + print *, a%faixa%localidade ! 'Areal' + print *, a%faixa%uf ! 'RJ' + print *, a%faixa%estado ! 'Rio de Janeiro' + print *, a%faixa%regiao ! 'Sudeste' + print *, a%faixa%ibge ! '3300225' + print *, a%faixa%formatted_cep_ini() ! '25845-001' + print *, a%faixa%formatted_cep_fim() ! '25849-899' + print *, a%faixa%ceps ! 118 +end if +``` + +> **It is still a 404.** `status` is `CEPIS_ERR_NOT_FOUND` and `a%found` is +> `.false.`. `faixa` is context for the error, never an address: the CEP does +> not exist and nothing there can be delivered to. + +Two things about `cep_ini` and `cep_fim`: + +- They are the **smallest and largest CEP that actually exist** in that + municipality, not the bounds the Correios publish. The DNE's own start for + Areal is `25845000` — precisely the CEP that just failed, which is why the + published bound would be no use here. +- **The range is sparse, and the pair is not a list.** Areal's 118 CEPs are + scattered over the ~4,900 numbers between `25845001` and `25849899`; almost + none of the numbers in between exist either. That is what `ceps` is for. + Show the range, never enumerate it, and check any single number with a + lookup. + +`has_faixa` is `.false.` whenever the API omits the block — it **omits it, it +never sends null** — which happens when the CEP falls in a band no municipality +owns. It is also `.false.` for a 404 whose body is malformed or unintelligible: +parsing the band can never turn a clean not-found into +`CEPIS_ERR_INVALID_RESPONSE`, because that would tell you to retry a CEP that +will never exist. + +Only `lookup` carries it. The batch behind `lookup_many` is CSV and has no +`faixa` column, so a CEP missing from a batch is just `found = .false.`. + `CEPIS_ERR_INVALID_RESPONSE` almost always means something between you and the API is answering instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error page. Useful as a diagnosis: the problem is not in your code. diff --git a/app/main.f90 b/app/main.f90 index 5e3f988..0029d6e 100644 --- a/app/main.f90 +++ b/app/main.f90 @@ -109,6 +109,7 @@ contains call handle%lookup(cep, address, code, detail) if (code /= CEPIS_OK) then write (error_unit, '(A)') 'cepis: '//cepis_status_name(code)//': '//detail + if (address%has_faixa) call show_faixa(address%faixa) stop 1 end if @@ -132,6 +133,19 @@ contains end if end subroutine show_one + !> What the API knows about a CEP that does not exist: the municipality it + !> would have fallen under. The lookup still failed, so this goes to stderr + !> with the error, and the exit status stays 1. + subroutine show_faixa(faixa) + type(cepis_faixa), intent(in) :: faixa + + write (error_unit, '(A)') ' faixa '//faixa%localidade//'/'// & + faixa%uf//' ibge '//faixa%ibge + write (error_unit, '(A,I0,A)') ' existentes '//faixa%formatted_cep_ini()// & + '..'//faixa%formatted_cep_fim()//' ', faixa%ceps, & + ' CEPs (the range is sparse; most numbers in it do not exist)' + end subroutine show_faixa + subroutine show_many(handle, values) type(cepis_client), intent(in) :: handle character(len=*), intent(in) :: values(:) 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 diff --git a/test/test_cepis.f90 b/test/test_cepis.f90 index 348e9df..9f22deb 100644 --- a/test/test_cepis.f90 +++ b/test/test_cepis.f90 @@ -16,7 +16,7 @@ program test_cepis use test_server implicit none - character(len=:), allocatable :: SE, PAULISTA, CITYWIDE, CSV_HEADER, HEALTH_BODY + character(len=:), allocatable :: SE, PAULISTA, CITYWIDE, AREAL, CSV_HEADER, HEALTH_BODY integer :: passed, failed type(c_ptr) :: server character(len=:), allocatable :: base @@ -48,6 +48,8 @@ program test_cepis call test_malformed_geo_does_not_lose_the_address() call test_new_api_field_does_not_break() call test_not_found_is_not_retried() + call test_not_found_carries_the_band() + call test_faixa_never_costs_the_not_found() call test_invalid_cep_never_leaves_the_machine() call test_api_400_becomes_invalid_cep() call test_response_that_is_not_json() @@ -74,7 +76,7 @@ program test_cepis call test_status_names_exist() call server_stop(server) - deallocate (SE, PAULISTA, CITYWIDE, CSV_HEADER, HEALTH_BODY, base) + deallocate (SE, PAULISTA, CITYWIDE, AREAL, CSV_HEADER, HEALTH_BODY, base) write (output_unit, '(A)') '' write (output_unit, '(A,I0,A,I0,A)') 'passed ', passed, ', failed ', failed, '' @@ -102,6 +104,17 @@ contains CITYWIDE = CITYWIDE//'"gia":"","ddd":"68","siafi":"0003","geo":{"lat":-9.9754,' CITYWIDE = CITYWIDE//'"lon":-67.8249,"raio_m":21400,"precisao":"localidade"}}' + ! A 404 that carries the band. 25845000 is the DNE's own published + ! start for Areal and does not exist; the CEPs that do start at + ! 25845001, and the 118 of them are scattered over the ~4,900 numbers + ! up to 25849899. + AREAL = '{"erro":"cep_nao_encontrado",' + AREAL = AREAL//'"mensagem":"CEP n\u00e3o existe na base do DNE",' + AREAL = AREAL//'"faixa":{"localidade":"Areal","uf":"RJ",' + AREAL = AREAL//'"estado":"Rio de Janeiro","regiao":"Sudeste",' + AREAL = AREAL//'"ibge":"3300225","cep_ini":"25845001",' + AREAL = AREAL//'"cep_fim":"25849899","ceps":118}}' + CSV_HEADER = 'cep,logradouro,complemento,unidade,bairro,localidade,uf,' CSV_HEADER = CSV_HEADER//'estado,regiao,ibge,gia,ddd,siafi' @@ -457,8 +470,97 @@ contains call check(status == CEPIS_ERR_NOT_FOUND, '404 becomes not found') call check(server_count(server, '/99999999') == 1, '404 is not retried') call check(len(message) > 0, 'not found explains itself') + call check(.not. address%has_faixa, 'a 404 with no faixa leaves has_faixa false') end subroutine test_not_found_is_not_retried + !> A 404 that names the municipality the CEP would have belonged to. + !> + !> 25845-000 is the case the field exists for: the DNE's own published + !> start for Areal, and a number that does not exist. Someone who knows the + !> city guessed the `-000`, as they do in the 957 municipalities that never + !> received one. + subroutine test_not_found_carries_the_band() + type(cepis_client) :: client + type(cepis_address) :: address + character(len=:), allocatable :: message + integer :: status + + call server_reset(server) + call server_respond(server, '/25845000', 404, AREAL, '') + call local_client(client) + + call client%lookup('25845000', address, status, message) + call check(status == CEPIS_ERR_NOT_FOUND, 'a faixa does not make the 404 a success') + call check(.not. address%found, 'the address is still not found') + call check(server_count(server, '/25845000') == 1, 'a 404 with a faixa is not retried') + + call check(address%has_faixa, 'has_faixa is true when the block is present') + call check(address%faixa%localidade == 'Areal', 'faixa names the municipality') + call check(address%faixa%uf == 'RJ', 'faixa uf') + call check(address%faixa%estado == 'Rio de Janeiro', 'faixa estado') + call check(address%faixa%regiao == 'Sudeste', 'faixa regiao') + call check(address%faixa%ibge == '3300225', 'faixa ibge') + call check(index(message, 'Areal') > 0, 'the message names the municipality') + + ! The bounds are the CEPs that exist, not the band the Correios + ! publish: the published start is 25845000, the CEP that just failed. + call check(address%faixa%cep_ini == '25845001', 'cep_ini is the smallest that exists') + call check(address%faixa%cep_fim == '25849899', 'cep_fim is the largest that exists') + call check(address%faixa%formatted_cep_ini() == '25845-001', 'cep_ini masks') + call check(address%faixa%formatted_cep_fim() == '25849-899', 'cep_fim masks') + + ! And they are not a list: 118 CEPs over ~4,900 numbers. + call check(address%faixa%ceps == 118, 'ceps counts what actually exists') + call check(address%faixa%ceps < 4899, 'the range is sparser than its span') + end subroutine test_not_found_carries_the_band + + !> Nothing about the faixa may turn a clean not-found into a failure. + subroutine test_faixa_never_costs_the_not_found() + type(cepis_client) :: client + type(cepis_address) :: address + character(len=:), allocatable :: message + integer :: status + + call server_reset(server) + call local_client(client) + + call server_respond(server, '/99999991', 404, & + '{"erro":"cep_nao_encontrado","faixa":null}', '') + call client%lookup('99999991', address, status, message) + call check(status == CEPIS_ERR_NOT_FOUND, 'a null faixa is still a not found') + call check(.not. address%has_faixa, 'a null faixa is absent, not present and empty') + + call server_respond(server, '/99999992', 404, & + '{"erro":"cep_nao_encontrado","faixa":"Areal"}', '') + call client%lookup('99999992', address, status, message) + call check(status == CEPIS_ERR_NOT_FOUND, 'a faixa that is not an object still 404s') + call check(.not. address%has_faixa, 'a faixa that is not an object is dropped') + + call server_respond(server, '/99999993', 404, & + '{"erro":"cep_nao_encontrado","faixa":{}}', '') + call client%lookup('99999993', address, status, message) + call check(status == CEPIS_ERR_NOT_FOUND, 'an empty faixa still 404s') + call check(.not. address%has_faixa, 'a faixa with no municipality is no faixa') + + call server_respond(server, '/99999994', 404, '<html>not found</html>', '') + call client%lookup('99999994', address, status, message) + call check(status == CEPIS_ERR_NOT_FOUND, 'a 404 body that is not JSON still 404s') + call check(.not. address%has_faixa, 'an unparseable 404 body carries no faixa') + + call server_respond(server, '/99999995', 404, '', '') + call client%lookup('99999995', address, status, message) + call check(status == CEPIS_ERR_NOT_FOUND, 'an empty 404 body still 404s') + call check(.not. address%has_faixa, 'an empty 404 body carries no faixa') + + call server_respond(server, '/99999996', 404, & + '{"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal",'// & + '"uf":"RJ","cep_ini":"25845001","cep_fim":"25849899"}}', '') + call client%lookup('99999996', address, status, message) + call check(address%has_faixa, 'a faixa missing its count is still worth having') + call check(address%faixa%ceps == 0, 'a missing count is zero, not a dropped block') + call check(address%faixa%estado == '', 'a missing string field is empty') + end subroutine test_faixa_never_costs_the_not_found + subroutine test_invalid_cep_never_leaves_the_machine() type(cepis_client) :: client type(cepis_address) :: address |