diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:38:27 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:38:27 -0300 |
| commit | 399eac351b1bc29d5047dbac96486c281be1b05c (patch) | |
| tree | 81151d5bf139f07096a8f38313d64482250163c2 | |
| parent | 11d49bc25eb1643f1b742f9527212d8781f2e0de (diff) | |
| download | r-0c07131a570366c4254d32d90020d6253620cbcc.tar.gz | |
A CEP that fails a lookup is rarely a random number: it is usually someone
who knows the city and guessed, and the classic guess is the -000 that 957
municipalities never received. The API now answers those 404s with a faixa
naming the municipality that owns the band the number fell into, which is
the difference between telling a user "invalid CEP" and "no such CEP in
Areal/RJ".
It is context for an error and never an address, so it rides on the
cepis_not_found condition instead of turning the failure into a success,
and cep_ini/cep_fim are the endpoints that exist rather than the range the
Correios publish - the interval between them is sparse, which is what ceps
counts. Parsing is deliberately unable to signal: a faixa that is null, an
array, a string or half a body leaves a clean not-found behind, because the
caller asked for the CEP, not for the decoration on its absence.
| -rw-r--r-- | DESCRIPTION | 9 | ||||
| -rw-r--r-- | NAMESPACE | 1 | ||||
| -rw-r--r-- | R/api.R | 16 | ||||
| -rw-r--r-- | R/conditions.R | 30 | ||||
| -rw-r--r-- | R/http.R | 14 | ||||
| -rw-r--r-- | R/models.R | 102 | ||||
| -rw-r--r-- | README.md | 57 | ||||
| -rw-r--r-- | man/cep_lookup.Rd | 5 | ||||
| -rw-r--r-- | man/cepis_conditions.Rd | 27 | ||||
| -rw-r--r-- | man/cepis_faixa.Rd | 53 | ||||
| -rw-r--r-- | tests/helper/utils.R | 11 | ||||
| -rw-r--r-- | tests/test-lookup.R | 99 |
12 files changed, 403 insertions, 21 deletions
diff --git a/DESCRIPTION b/DESCRIPTION index c6eb473..801e741 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: cepis Title: Client for the Public CEP API at cep.is -Version: 0.1.0 +Version: 0.2.0 Authors@R: person("Murilo", "Ijanc", email = "[email protected]", role = c("aut", "cre")) @@ -8,9 +8,10 @@ Description: Looks up Brazilian postal codes (CEP) against the public API at <https://cep.is>, which serves official Correios data (DNE) refreshed every two weeks. Resolves a single CEP, batches of up to 500 per request with the order preserved, the coordinate the database has for about 57% - of them, and the API's health. Retries transient failures with - exponentially backed-off, jittered waits and never retries a definitive - answer. + of them, and the API's health. A CEP that does not exist reports the + municipality whose range it fell into, when it fell into one. Retries + transient failures with exponentially backed-off, jittered waits and + never retries a definitive answer. License: file LICENSE URL: https://cep.is Encoding: UTF-8 @@ -2,6 +2,7 @@ S3method(print,cepis_address) S3method(print,cepis_client) +S3method(print,cepis_faixa) S3method(print,cepis_geo) S3method(print,cepis_health) export(cep_health) @@ -13,7 +13,10 @@ #' @section Conditions: #' \describe{ #' \item{`cepis_invalid_cep`}{the CEP is not 8 digits. No request is made.} -#' \item{`cepis_not_found`}{well-formed, but not in the database.} +#' \item{`cepis_not_found`}{well-formed, but not in the database. Carries +#' `faixa`, a [cepis_faixa] naming the municipality whose range the CEP +#' fell into, when it fell into one. Still a not-found: the CEP does not +#' exist, and a `faixa` is not an address.} #' \item{`cepis_rate_limited`}{429 on every attempt.} #' \item{`cepis_server_error`}{5xx on every attempt.} #' \item{`cepis_network_error`}{the request never completed.} @@ -38,10 +41,13 @@ cep_lookup <- function(cep, client = default_client()) { response <- cepis_fetch(client, paste0("/", number)) if (response$status == 404L) { - stop(not_found_error( - paste0("CEP ", number, " does not exist in the DNE database"), - cep = number - )) + faixa <- new_faixa(json_field(response$body, "faixa")) + message <- paste0("CEP ", number, " does not exist in the DNE database") + if (!is.null(faixa)) { + message <- paste0(message, "; the range it falls in belongs to ", + faixa$localidade, "/", faixa$uf) + } + stop(not_found_error(message, cep = number, faixa = faixa)) } if (response$status == 400L) { stop(invalid_cep_error( diff --git a/R/conditions.R b/R/conditions.R index 1eebe5b..7cdb45b 100644 --- a/R/conditions.R +++ b/R/conditions.R @@ -38,6 +38,32 @@ #' proxy, an HTML error page. Useful as a diagnosis: the problem is not in #' your code. #' +#' @section What cepis_not_found carries: +#' `cep`, the 8 digits as they went to the API, and `faixa`: a [cepis_faixa] +#' naming the municipality that owns the band of numbers the CEP fell into, +#' or `NULL` when it fell in a band nobody owns. The API omits it, it never +#' sends null, so `NULL` here is the same statement. +#' +#' It changes nothing about the answer — the CEP still does not exist, and a +#' `faixa` is not an address — but it is usually the difference between +#' telling someone "invalid CEP" and telling them "no such CEP in Areal/RJ": +#' +#' ```r +#' tryCatch( +#' cep_lookup("25845000"), +#' cepis_not_found = function(e) { +#' if (is.null(e$faixa)) { +#' "no such CEP" +#' } else { +#' paste0("no such CEP in ", e$faixa$localidade, "/", e$faixa$uf) +#' } +#' } +#' ) +#' ``` +#' +#' The CEP that failed above is the DNE's own first number for Areal, and it +#' is the shape of guess this field is for. +#' #' @name cepis_conditions NULL @@ -52,8 +78,8 @@ invalid_cep_error <- function(message, value = NULL) { cepis_condition("cepis_invalid_cep", message, value = value) } -not_found_error <- function(message, cep = NULL) { - cepis_condition("cepis_not_found", message, cep = cep) +not_found_error <- function(message, cep = NULL, faixa = NULL) { + cepis_condition("cepis_not_found", message, cep = cep, faixa = faixa) } rate_limited_error <- function(message, retry_after = NULL) { @@ -173,12 +173,22 @@ json_object <- function(body) { data } -api_message <- function(body, fallback) { +# The counterpart of json_object() for a body that is already an error: it +# never signals, because a broken error body must not replace the error the +# caller is being told about. Missing, malformed, empty: all of them NULL. +json_field <- function(body, name) { data <- tryCatch( jsonlite::parse_json(decode_body(body), simplifyVector = FALSE), error = function(e) NULL ) - message <- if (is.list(data)) data[["mensagem"]] else NULL + if (!is.list(data) || is.null(names(data))) { + return(NULL) + } + data[[name]] +} + +api_message <- function(body, fallback) { + message <- json_field(body, "mensagem") if (is.character(message) && length(message) == 1L && nzchar(message)) { message } else { @@ -62,6 +62,53 @@ NULL #' @name cepis_geo NULL +#' The municipality a CEP that does not exist would have belonged to +#' +#' @description +#' The `faixa` field of a [cepis_not_found][cepis_conditions] condition, a +#' plain list of class `cepis_faixa`. It is **context for an error, not an +#' address**: the CEP asked for does not exist, and nothing here says +#' otherwise. What it does say is which municipality owns the band of numbers +#' the CEP fell into. +#' +#' It exists because a CEP that fails is rarely random — it is usually +#' someone who knows the city and guessed the number, and the classic guess is +#' the `-000` that 957 municipalities never received. +#' +#' @section Fields: +#' \describe{ +#' \item{`localidade`, `uf`, `estado`, `regiao`}{municipality, state code, +#' state name, region — the same names, and the same meaning, as in a +#' [cepis_address].} +#' \item{`ibge`}{the municipality's IBGE code, so the answer can be joined +#' to other data without matching on the name.} +#' \item{`cep_ini`, `cep_fim`}{the smallest and the largest CEP that +#' **actually exists** in that municipality. Not the range the Correios +#' publish: the DNE's own start for Areal/RJ is `25845000`, which is +#' precisely the CEP that just failed.} +#' \item{`ceps`}{how many CEPs the municipality really has.} +#' } +#' +#' @section The range is sparse: +#' Areal's 118 CEPs are scattered over the ~4900 numbers between `25845001` +#' and `25849899`. `cep_ini` and `cep_fim` are two endpoints that exist, not a +#' promise about anything in between — never present the pair as a list of +#' valid CEPs, and never iterate from one to the other. `ceps` is there so the +#' emptiness of the interval is visible: 118 out of 4899. +#' +#' @section When there is no faixa: +#' `NULL`, and not a list of empty strings, when the CEP falls in a band no +#' municipality owns. The API **omits** the field rather than sending null, +#' and this package keeps that distinction intact: `is.null(x$faixa)` means +#' the number is close to nothing, not that the fields came back blank. +#' +#' Only [cep_lookup()] reports it. In [cep_lookup_many()] a CEP that does not +#' exist is still `NULL` at its position: the endpoint behind a batch is CSV +#' and says nothing about ranges. +#' +#' @name cepis_faixa +NULL + #' The answer of `GET /health` #' #' @description @@ -96,6 +143,15 @@ text_field <- function(data, name) { as.character(value) } +count_field <- function(data, name) { + value <- data[[name]] + if (!is.numeric(value) && !is.character(value)) { + return(0L) + } + value <- suppressWarnings(as.numeric(value)) + if (length(value) != 1L || is.na(value)) 0L else as.integer(value) +} + new_geo <- function(block) { lat <- suppressWarnings(as.numeric(block[["lat"]])) lon <- suppressWarnings(as.numeric(block[["lon"]])) @@ -121,6 +177,35 @@ new_geo <- function(block) { ) } +# Never signals. A faixa is decoration on a 404, and a malformed one must not +# turn a clean "this CEP does not exist" into a parse failure: anything that +# is not an object naming a municipality is simply no faixa at all. +new_faixa <- function(block) { + if (!is.list(block) || is.null(names(block))) { + return(NULL) + } + + localidade <- text_field(block, "localidade") + uf <- text_field(block, "uf") + if (!nzchar(localidade) || !nzchar(uf)) { + return(NULL) + } + + structure( + list( + localidade = localidade, + uf = uf, + estado = text_field(block, "estado"), + regiao = text_field(block, "regiao"), + ibge = text_field(block, "ibge"), + cep_ini = text_field(block, "cep_ini"), + cep_fim = text_field(block, "cep_fim"), + ceps = count_field(block, "ceps") + ), + class = "cepis_faixa" + ) +} + new_address <- function(data) { geo <- NULL block <- data[["geo"]] @@ -135,10 +220,6 @@ new_address <- function(data) { } new_health <- function(data) { - count <- function(name) { - value <- suppressWarnings(as.numeric(data[[name]])) - if (length(value) != 1L || is.na(value)) 0L else as.integer(value) - } status <- text_field(data, "status") structure( @@ -146,8 +227,8 @@ new_health <- function(data) { status = status, ok = identical(status, "ok"), dne = text_field(data, "dne"), - ceps = count("ceps"), - geo = count("geo"), + ceps = count_field(data, "ceps"), + geo = count_field(data, "geo"), base_gerada_em = text_field(data, "base_gerada_em") ), class = "cepis_health" @@ -204,6 +285,15 @@ print.cepis_geo <- function(x, ...) { } #' @export +print.cepis_faixa <- function(x, ...) { + cat("<cepis_faixa> ", x$localidade, "/", x$uf, " (IBGE ", x$ibge, ")\n", + sep = "") + cat(" ", x$ceps, " CEP(s), the first ", formatted_cep(x$cep_ini), + " and the last ", formatted_cep(x$cep_fim), "\n", sep = "") + invisible(x) +} + +#' @export print.cepis_health <- function(x, ...) { cat("<cepis_health> ", x$status, "\n", sep = "") cat(" dne: ", x$dne, " (generated ", x$base_gerada_em, ")\n", sep = "") @@ -167,7 +167,7 @@ Failures are classed conditions. Everything inherits `cepis_error` (and | class | when | does retrying help? | | --- | --- | --- | | `cepis_invalid_cep` | not 8 digits | no | -| `cepis_not_found` | well-formed, not in the database | no | +| `cepis_not_found` | well-formed, not in the database; carries `faixa` | no | | `cepis_rate_limited` | 429; carries `retry_after` | yes, later | | `cepis_server_error` | 5xx; carries `status` | yes, later | | `cepis_network_error` | DNS, connection, timeout, TLS | yes, later | @@ -185,6 +185,61 @@ tryCatch( 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. +### Where a CEP that does not exist would have been + +A CEP that fails is rarely random: it is usually someone who knows the city +and guessed the number, and the classic guess is the `-000` that 957 +municipalities never received. When the number falls inside the range of a +municipality, `cepis_not_found` carries a `faixa` naming it: + +```r +tryCatch( + cep_lookup("25845000"), + cepis_not_found = function(e) { + if (is.null(e$faixa)) { + "no such CEP" + } else { + paste0("no such CEP in ", e$faixa$localidade, "/", e$faixa$uf) + } + } +) +#> "no such CEP in Areal/RJ" +``` + +The lookup **still failed**, and a `faixa` is not an address — it is context +for the error, and answers "did you mean somewhere in Areal?" instead of +"invalid CEP". The condition is the same `cepis_not_found` as before; code +that ignores `faixa` keeps working. + +```r +f <- e$faixa + +f$localidade # "Areal" +f$uf # "RJ" +f$estado # "Rio de Janeiro" +f$regiao # "Sudeste" +f$ibge # "3300225" +f$cep_ini # "25845001" +f$cep_fim # "25849899" +f$ceps # 118 +``` + +Two things about `cep_ini` and `cep_fim`: + +- They are the smallest and the largest CEP that **actually exist** there, + not the range the Correios publish. The DNE's own start for Areal is + `25845000` — exactly the CEP that just failed above. +- **The range is sparse.** Areal's 118 CEPs are scattered over the ~4900 + numbers between the two endpoints. `ceps` is there so that is visible: + never present the pair as a list of valid CEPs, and never iterate from one + to the other. + +`faixa` is `NULL` when the CEP falls in a band no municipality owns. The API +omits the field rather than sending null, so `is.null(e$faixa)` says the +number is close to nothing, not that the fields came back blank. Batches do +not report it either: `cep_lookup_many()` still gives `NULL` at that +position, because the endpoint behind it is CSV. + ## Development Everything runs through `make`: diff --git a/man/cep_lookup.Rd b/man/cep_lookup.Rd index 3c22a1e..f6f2d0b 100644 --- a/man/cep_lookup.Rd +++ b/man/cep_lookup.Rd @@ -23,7 +23,10 @@ has for it, including the coordinate when there is one. \describe{ \item{\code{cepis_invalid_cep}}{the CEP is not 8 digits. No request is made.} -\item{\code{cepis_not_found}}{well-formed, but not in the database.} +\item{\code{cepis_not_found}}{well-formed, but not in the database. Carries +\code{faixa}, a \link{cepis_faixa} naming the municipality whose range the CEP +fell into, when it fell into one. Still a not-found: the CEP does not +exist, and a \code{faixa} is not an address.} \item{\code{cepis_rate_limited}}{429 on every attempt.} \item{\code{cepis_server_error}}{5xx on every attempt.} \item{\code{cepis_network_error}}{the request never completed.} diff --git a/man/cepis_conditions.Rd b/man/cepis_conditions.Rd index 4def4ff..9b25cbe 100644 --- a/man/cepis_conditions.Rd +++ b/man/cepis_conditions.Rd @@ -40,3 +40,30 @@ 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. } +\section{What cepis_not_found carries}{ + +\code{cep}, the 8 digits as they went to the API, and \code{faixa}: a \link{cepis_faixa} +naming the municipality that owns the band of numbers the CEP fell into, +or \code{NULL} when it fell in a band nobody owns. The API omits it, it never +sends null, so \code{NULL} here is the same statement. + +It changes nothing about the answer — the CEP still does not exist, and a +\code{faixa} is not an address — but it is usually the difference between +telling someone "invalid CEP" and telling them "no such CEP in Areal/RJ": + +\if{html}{\out{<div class="sourceCode r">}}\preformatted{tryCatch( + cep_lookup("25845000"), + cepis_not_found = function(e) \{ + if (is.null(e$faixa)) \{ + "no such CEP" + \} else \{ + paste0("no such CEP in ", e$faixa$localidade, "/", e$faixa$uf) + \} + \} +) +}\if{html}{\out{</div>}} + +The CEP that failed above is the DNE's own first number for Areal, and it +is the shape of guess this field is for. +} + diff --git a/man/cepis_faixa.Rd b/man/cepis_faixa.Rd new file mode 100644 index 0000000..5d3f7ff --- /dev/null +++ b/man/cepis_faixa.Rd @@ -0,0 +1,53 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/models.R +\name{cepis_faixa} +\alias{cepis_faixa} +\title{The municipality a CEP that does not exist would have belonged to} +\description{ +The \code{faixa} field of a \link[=cepis_conditions]{cepis_not_found} condition, a +plain list of class \code{cepis_faixa}. It is \strong{context for an error, not an +address}: the CEP asked for does not exist, and nothing here says +otherwise. What it does say is which municipality owns the band of numbers +the CEP fell into. + +It exists because a CEP that fails is rarely random — it is usually +someone who knows the city and guessed the number, and the classic guess is +the \code{-000} that 957 municipalities never received. +} +\section{Fields}{ + +\describe{ +\item{\code{localidade}, \code{uf}, \code{estado}, \code{regiao}}{municipality, state code, +state name, region — the same names, and the same meaning, as in a +\link{cepis_address}.} +\item{\code{ibge}}{the municipality's IBGE code, so the answer can be joined +to other data without matching on the name.} +\item{\code{cep_ini}, \code{cep_fim}}{the smallest and the largest CEP that +\strong{actually exists} in that municipality. Not the range the Correios +publish: the DNE's own start for Areal/RJ is \code{25845000}, which is +precisely the CEP that just failed.} +\item{\code{ceps}}{how many CEPs the municipality really has.} +} +} + +\section{The range is sparse}{ + +Areal's 118 CEPs are scattered over the ~4900 numbers between \code{25845001} +and \code{25849899}. \code{cep_ini} and \code{cep_fim} are two endpoints that exist, not a +promise about anything in between — never present the pair as a list of +valid CEPs, and never iterate from one to the other. \code{ceps} is there so the +emptiness of the interval is visible: 118 out of 4899. +} + +\section{When there is no faixa}{ + +\code{NULL}, and not a list of empty strings, when the CEP falls in a band no +municipality owns. The API \strong{omits} the field rather than sending null, +and this package keeps that distinction intact: \code{is.null(x$faixa)} means +the number is close to nothing, not that the fields came back blank. + +Only \code{\link[=cep_lookup]{cep_lookup()}} reports it. In \code{\link[=cep_lookup_many]{cep_lookup_many()}} a CEP that does not +exist is still \code{NULL} at its position: the endpoint behind a batch is CSV +and says nothing about ranges. +} + diff --git a/tests/helper/utils.R b/tests/helper/utils.R index 3c24a2f..94a15ff 100644 --- a/tests/helper/utils.R +++ b/tests/helper/utils.R @@ -16,6 +16,17 @@ SE_ADDRESS <- list( siafi = "7107" ) +AREAL_FAIXA <- list( + localidade = "Areal", + uf = "RJ", + estado = "Rio de Janeiro", + regiao = "Sudeste", + ibge = "3300225", + cep_ini = "25845001", + cep_fim = "25849899", + ceps = 118L +) + CSV_HEADER <- paste0( "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,", "ibge,gia,ddd,siafi" diff --git a/tests/test-lookup.R b/tests/test-lookup.R index 0223f4d..3ff4646 100644 --- a/tests/test-lookup.R +++ b/tests/test-lookup.R @@ -82,6 +82,105 @@ with_server( } ) +not_found_body <- function(faixa = NULL) { + body <- list( + erro = "cep_nao_encontrado", + mensagem = "CEP n\u00e3o existe na base do DNE" + ) + if (!is.null(faixa)) { + body$faixa <- faixa + } + json(body) +} + +# 25845000 is the DNE's own first number for Areal, and one of the 957 that +# were never issued: the guess the faixa exists to answer. +with_server( + list(route("/25845000", response(404L, not_found_body(AREAL_FAIXA)))), + function(client, server) { + condition <- check_condition( + "a CEP inside a municipality's range is still not found", + cep_lookup("25845000", client), + "cepis_not_found" + ) + faixa <- condition$faixa + check("the condition carries the faixa", inherits(faixa, "cepis_faixa")) + check("the faixa names the municipality", identical(faixa$localidade, "Areal")) + check("the faixa carries the state", identical(faixa$uf, "RJ")) + check("the faixa carries the IBGE code", identical(faixa$ibge, "3300225")) + check("the faixa counts the CEPs that exist", identical(faixa$ceps, 118L)) + check( + "the bounds are the CEPs that exist, unmasked", + identical(c(faixa$cep_ini, faixa$cep_fim), c("25845001", "25849899")) + ) + check( + "the message names the municipality", + grepl("Areal/RJ", conditionMessage(condition), fixed = TRUE) + ) + check("the faixa prints", length(capture.output(print(faixa))) == 2L) + check("a 404 with a faixa is not retried either", calls(server, "/25845000") == 1L) + } +) + +with_server( + list(route("/99999999", response(404L, not_found_body()))), + function(client, server) { + condition <- check_condition( + "a CEP in a band nobody owns is not found too", + cep_lookup("99999999", client), + "cepis_not_found" + ) + check("an omitted faixa is NULL", is.null(condition$faixa)) + check("the CEP is still carried", identical(condition$cep, "99999999")) + check( + "the message claims no range", + !grepl("belongs to", conditionMessage(condition), fixed = TRUE) + ) + } +) + +# A faixa is decoration on an error. None of these may cost the caller the +# error itself, which is the only thing it asked for. +unusable <- list( + "a null faixa" = "{\"erro\":\"x\",\"faixa\":null}", + "an empty faixa" = "{\"erro\":\"x\",\"faixa\":{}}", + "a faixa that is an array" = "{\"erro\":\"x\",\"faixa\":[]}", + "a faixa that is a string" = "{\"erro\":\"x\",\"faixa\":\"Areal\"}", + "a faixa with no municipality" = "{\"erro\":\"x\",\"faixa\":{\"uf\":\"RJ\"}}", + "a faixa with an empty municipality" = + "{\"erro\":\"x\",\"faixa\":{\"localidade\":\"\",\"uf\":\"RJ\"}}", + "a truncated body" = "{\"erro\":\"cep_nao", + "a body that is not JSON at all" = "<html>captive portal</html>", + "an empty body" = "" +) + +for (label in names(unusable)) { + with_server( + list(route("/99999999", response(404L, unusable[[label]]))), + function(client, server) { + condition <- check_condition( + paste0(label, " is still a clean cepis_not_found"), + cep_lookup("99999999", client), + "cepis_not_found" + ) + check(paste0(label, " yields no faixa"), is.null(condition$faixa)) + } + ) +} + +odd_count <- c(AREAL_FAIXA[names(AREAL_FAIXA) != "ceps"], list(ceps = "many")) +with_server( + list(route("/25845000", response(404L, not_found_body(odd_count)))), + function(client, server) { + condition <- caught(cep_lookup("25845000", client)) + check( + "an unreadable count does not lose the municipality", + identical(condition$faixa$localidade, "Areal") + ) + check("an unreadable count reads as zero", identical(condition$faixa$ceps, 0L)) + } +) + with_server( list(route("/01001000", response(200L, json(SE_ADDRESS)))), function(client, server) { |