diff options
| -rw-r--r-- | .Rbuildignore | 8 | ||||
| -rw-r--r-- | .gitignore | 6 | ||||
| -rw-r--r-- | DESCRIPTION | 25 | ||||
| -rw-r--r-- | LICENSE | 15 | ||||
| -rw-r--r-- | Makefile | 59 | ||||
| -rw-r--r-- | NAMESPACE | 12 | ||||
| -rw-r--r-- | R/api.R | 182 | ||||
| -rw-r--r-- | R/cepis-package.R | 24 | ||||
| -rw-r--r-- | R/client.R | 122 | ||||
| -rw-r--r-- | R/conditions.R | 73 | ||||
| -rw-r--r-- | R/http.R | 210 | ||||
| -rw-r--r-- | R/models.R | 212 | ||||
| -rw-r--r-- | R/normalize.R | 43 | ||||
| -rw-r--r-- | README.md | 207 | ||||
| -rw-r--r-- | man/cep_health.Rd | 34 | ||||
| -rw-r--r-- | man/cep_lookup.Rd | 46 | ||||
| -rw-r--r-- | man/cep_lookup_many.Rd | 51 | ||||
| -rw-r--r-- | man/cepis-package.Rd | 38 | ||||
| -rw-r--r-- | man/cepis_address.Rd | 34 | ||||
| -rw-r--r-- | man/cepis_client.Rd | 64 | ||||
| -rw-r--r-- | man/cepis_conditions.Rd | 42 | ||||
| -rw-r--r-- | man/cepis_geo.Rd | 36 | ||||
| -rw-r--r-- | man/cepis_health.Rd | 23 | ||||
| -rw-r--r-- | man/formatted_cep.Rd | 25 | ||||
| -rw-r--r-- | man/normalize_cep.Rd | 33 | ||||
| -rw-r--r-- | tests/helper/server.R | 114 | ||||
| -rw-r--r-- | tests/helper/utils.R | 127 | ||||
| -rw-r--r-- | tests/test-batch.R | 78 | ||||
| -rw-r--r-- | tests/test-config.R | 27 | ||||
| -rw-r--r-- | tests/test-health.R | 33 | ||||
| -rw-r--r-- | tests/test-lookup.R | 124 | ||||
| -rw-r--r-- | tests/test-normalize.R | 39 | ||||
| -rw-r--r-- | tests/test-retry.R | 121 |
33 files changed, 2287 insertions, 0 deletions
diff --git a/.Rbuildignore b/.Rbuildignore new file mode 100644 index 0000000..2567dbf --- /dev/null +++ b/.Rbuildignore @@ -0,0 +1,8 @@ +^Makefile$ +^\.gitignore$ +^\.git$ +^docs$ +^CLAUDE\.md$ +^.*\.Rcheck$ +^.*\.tar\.gz$ +^check$ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39af533 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.tar.gz +*.Rcheck/ +.Rhistory +.RData +.Rproj.user/ +check/ diff --git a/DESCRIPTION b/DESCRIPTION new file mode 100644 index 0000000..c6eb473 --- /dev/null +++ b/DESCRIPTION @@ -0,0 +1,25 @@ +Package: cepis +Title: Client for the Public CEP API at cep.is +Version: 0.1.0 +Authors@R: + person("Murilo", "Ijanc", email = "[email protected]", + role = c("aut", "cre")) +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. +License: file LICENSE +URL: https://cep.is +Encoding: UTF-8 +Depends: + R (>= 4.0) +Imports: + curl, + jsonlite, + stats, + utils +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.3.2 @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Murilo Ijanc <[email protected]> + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..955f76e --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +# Development tasks. Everything runs through `R`, so there is nothing to +# install first beyond the two runtime dependencies (curl, jsonlite) and +# roxygen2 for `make document`. +# +# make list the targets +# make test install into check/lib and run tests/ +# make lint parse every file + codetools, no files touched +# make build build the source tarball +R ?= R +RSCRIPT ?= Rscript + +PKG := cepis +VERSION := $(shell awk '/^Version:/ { print $$2 }' DESCRIPTION) +TARBALL := $(PKG)_$(VERSION).tar.gz +LIB := $(CURDIR)/check/lib + +.DEFAULT_GOAL := help +.PHONY: help install test lint document build check check-cran clean + +help: + @echo 'Targets:' + @echo ' test install into check/lib and run every tests/test-*.R' + @echo ' lint parse the sources + codetools::checkUsagePackage (read-only)' + @echo ' document regenerate man/ and NAMESPACE with roxygen2 (writes)' + @echo ' build build $(TARBALL)' + @echo ' check lint + test + build, in that order' + @echo ' check-cran build, then R CMD check the tarball' + @echo ' clean remove build and check artefacts' + +install: + @mkdir -p $(LIB) + $(R) CMD INSTALL --library=$(LIB) . + +test: install + @cd tests && for file in test-*.R; do \ + echo "==> $$file"; \ + R_LIBS=$(LIB) $(RSCRIPT) "$$file" || exit 1; \ + done + +# Read-only by design, so it is safe in CI and in a pre-commit hook. +lint: install + @R_LIBS=$(LIB) $(RSCRIPT) -e 'for (f in list.files(c("R", "tests"), pattern = "[.]R$$", recursive = TRUE, full.names = TRUE)) invisible(parse(f)); cat("parsed\n")' + @R_LIBS=$(LIB) $(RSCRIPT) -e 'suppressMessages(library($(PKG))); codetools::checkUsagePackage("$(PKG)", suppressLocalUnused = TRUE); cat("checked\n")' + +document: + $(RSCRIPT) -e 'roxygen2::roxygenise(load_code = "source")' + +build: + $(R) CMD build . + +# The order matters: lint is the fastest to fail, build the slowest, so a +# broken run reports the cheapest problem first. +check: lint test build + +check-cran: build + $(R) CMD check --no-manual $(TARBALL) + +clean: + rm -rf check $(PKG).Rcheck *.tar.gz diff --git a/NAMESPACE b/NAMESPACE new file mode 100644 index 0000000..406e52a --- /dev/null +++ b/NAMESPACE @@ -0,0 +1,12 @@ +# Generated by roxygen2: do not edit by hand + +S3method(print,cepis_address) +S3method(print,cepis_client) +S3method(print,cepis_geo) +S3method(print,cepis_health) +export(cep_health) +export(cep_lookup) +export(cep_lookup_many) +export(cepis_client) +export(formatted_cep) +export(normalize_cep) @@ -0,0 +1,182 @@ +#' Look up a single CEP +#' +#' @description +#' Accepts the CEP masked or unmasked and returns everything the DNE database +#' has for it, including the coordinate when there is one. +#' +#' @param cep A length-one character vector: `"01001-000"` or `"01001000"`. +#' @param client A [cepis_client()]. Defaults to a shared client built with +#' the default configuration. +#' +#' @return A [cepis_address]. +#' +#' @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_rate_limited`}{429 on every attempt.} +#' \item{`cepis_server_error`}{5xx on every attempt.} +#' \item{`cepis_network_error`}{the request never completed.} +#' \item{`cepis_invalid_response`}{a success status with an unreadable +#' body.} +#' } +#' +#' All of them inherit `cepis_error`; see [cepis_conditions]. +#' +#' @examples +#' \donttest{ +#' address <- cep_lookup("01001-000") +#' address$localidade +#' if (!is.null(address$geo) && address$geo$is_precise) { +#' c(address$geo$lat, address$geo$lon) +#' } +#' } +#' +#' @export +cep_lookup <- function(cep, client = default_client()) { + number <- normalize_cep(cep) + 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 + )) + } + if (response$status == 400L) { + stop(invalid_cep_error( + api_message(response$body, paste0("CEP ", number, " rejected")), + value = cep + )) + } + if (response$status != 200L) { + stop(server_error( + paste0("the API answered ", response$status, " for CEP ", number), + status = response$status + )) + } + + new_address(json_object(response$body)) +} + +#' Look up many CEPs in one request +#' +#' @description +#' Resolves up to 500 CEPs per request, chunking longer inputs automatically. +#' Do not loop over [cep_lookup()]: a loop of 500 calls pays 500 TLS +#' handshakes, the batch pays one. +#' +#' @param ceps A character vector of CEPs, masked or unmasked. +#' @param client A [cepis_client()]. Defaults to a shared client built with +#' the default configuration. +#' +#' @return A list of **the same length and in the same order** as `ceps`. +#' Each element is a [cepis_address], or `NULL` where the CEP does not +#' exist or is malformed — it does not vanish from the list, and it does not +#' signal a condition. That is the right difference from [cep_lookup()]: in +#' a batch of a thousand, one bad CEP must not cost the other nine hundred +#' and ninety-nine. Use [normalize_cep()] beforehand if you need to know +#' *which* input was malformed. +#' +#' A list rather than a `data.frame` because `NULL` is the only unambiguous +#' "no such CEP" in R: a row of `NA`s reads as an address with unknown +#' fields, which is a different statement, and an empty row cannot be told +#' apart from a CEP the database knows only by number. Rectangle it with +#' `do.call(rbind, lapply(result, function(a) as.data.frame(a[1:13])))` when +#' a `data.frame` is what you want. +#' +#' @section Batches carry no coordinate: +#' The endpoint behind them is CSV, and the CSV has no `geo` column. Every +#' address returned here has `geo = NULL`, including for CEPs that do have +#' one. Need `geo`? Use [cep_lookup()], one at a time. +#' +#' @examples +#' \donttest{ +#' ceps <- c("01001000", "99999999", "20040020") +#' result <- cep_lookup_many(ceps) +#' vapply(result, function(a) if (is.null(a)) NA_character_ else a$localidade, +#' character(1)) +#' } +#' +#' @export +cep_lookup_many <- function(ceps, client = default_client()) { + if (length(ceps) == 0L) { + return(list()) + } + ceps <- as.character(ceps) + + result <- list() + starts <- seq(1L, length(ceps), by = MAX_BATCH) + for (start in starts) { + chunk <- ceps[start:min(start + MAX_BATCH - 1L, length(ceps))] + result <- c(result, lookup_chunk(client, chunk)) + } + result +} + +lookup_chunk <- function(client, ceps) { + query <- utils::URLencode(paste(ceps, collapse = ","), reserved = TRUE) + response <- cepis_fetch(client, paste0("/csv?ceps=", query)) + + if (response$status == 400L) { + stop(invalid_cep_error( + api_message(response$body, "the API rejected the batch"), + value = ceps + )) + } + if (response$status != 200L) { + stop(server_error( + paste0("the API answered ", response$status, " for a batch of ", + length(ceps), " CEP(s)"), + status = response$status + )) + } + + table <- csv_rows(response$body) + rows <- vector("list", nrow(table)) + for (index in seq_len(nrow(table))) { + row <- as.list(table[index, , drop = FALSE]) + localidade <- row[["localidade"]] + if (is.null(localidade) || !nzchar(trimws(localidade))) { + next + } + rows[[index]] <- new_address(row) + } + + rows[seq_along(ceps)] +} + +#' Ask the API how it is, and which database it is serving +#' +#' @description +#' Useful for two things: checking that the API answers, and knowing which +#' version of the DNE database is live. The database changes every two weeks. +#' +#' @param client A [cepis_client()]. Defaults to a shared client built with +#' the default configuration. +#' +#' @return A [cepis_health]. +#' +#' @section Conditions: +#' An API reporting itself degraded is not a failure here — that arrives in +#' `status`, and checking health is exactly when you want the diagnosis +#' rather than an error. Network failures still signal `cepis_network_error`. +#' +#' @examples +#' \donttest{ +#' health <- cep_health() +#' health$ok +#' health$dne +#' } +#' +#' @export +cep_health <- function(client = default_client()) { + response <- cepis_fetch(client, "/health", accepted_status = c(200L, 503L)) + if (!(response$status %in% c(200L, 503L))) { + stop(server_error( + paste0("the API answered ", response$status, " at /health"), + status = response$status + )) + } + new_health(json_object(response$body)) +} diff --git a/R/cepis-package.R b/R/cepis-package.R new file mode 100644 index 0000000..c028b02 --- /dev/null +++ b/R/cepis-package.R @@ -0,0 +1,24 @@ +#' cepis: client for the public CEP API at cep.is +#' +#' @description +#' Resolves Brazilian postal codes against <https://cep.is>. Official +#' Correios data (DNE), refreshed every two weeks. +#' +#' ```r +#' address <- cep_lookup("01001-000") +#' address$logradouro # "Praca da Se" +#' address$localidade # "Sao Paulo" +#' ``` +#' +#' The API surface is English, but the **data fields keep the API's own +#' Portuguese names** (`logradouro`, `bairro`, `localidade`, `uf`). What you +#' read in the JSON at <https://cep.is/01001000> is what you read on the +#' returned object, with no translation layer in between. +#' +#' Start at [cep_lookup()] for one CEP, [cep_lookup_many()] for many, +#' [cep_health()] for the API's status and [cepis_client()] for anything +#' beyond the default configuration. Failures are classed conditions, +#' described in [cepis_conditions]. +#' +#' @keywords internal +"_PACKAGE" diff --git a/R/client.R b/R/client.R new file mode 100644 index 0000000..cdd72f0 --- /dev/null +++ b/R/client.R @@ -0,0 +1,122 @@ +DEFAULT_BASE_URL <- "https://cep.is" + +MAX_BATCH <- 500L + +RETRIABLE_STATUS <- c(429L, 500L, 502L, 503L, 504L) + +#' Build a client for the cep.is API +#' +#' @description +#' Holds the configuration and nothing else: no connection, no state between +#' calls. Build one and reuse it, or skip it entirely — [cep_lookup()], +#' [cep_lookup_many()] and [cep_health()] fall back to a shared default +#' client built with these defaults. +#' +#' @param base_url Where the API lives. Point it at a local instance (the API +#' repo brings one up on `http://localhost:8080` with `just up`) or at a +#' test server. It is the only parameter that usually changes between +#' environments. Trailing slashes are stripped, so it cannot produce +#' doubled separators that some proxies normalise and others 404. +#' @param timeout Seconds allowed for **one attempt**, connection and +#' transfer together. It is not a cap on the whole call, since a call may +#' make several attempts; that cap is `total_timeout`. +#' @param max_attempts How many attempts in total, counting the first. `1` +#' disables retrying. The default of `3` covers the common case (one +#' transient failure) without turning a real outage into a long wait. +#' @param backoff_base Base of the exponential wait, in seconds. The actual +#' wait is drawn from `[0, base * 2^(n - 1)]` — *full jitter*. The +#' randomness is not decoration: without it, N clients that fail together +#' come back together and reproduce the spike that took the service down. +#' @param backoff_max Cap on each individual wait, in seconds. It applies to +#' a server-sent `Retry-After` too, or a `Retry-After: 3600` from a +#' misconfigured proxy would park the session for an hour. +#' @param total_timeout Cap on the whole call, attempts and waits included. +#' `NULL` leaves `max_attempts` as the only bound. Set it in code serving a +#' web request: without it the worst case is `max_attempts * timeout` plus +#' the sum of the backoffs. +#' @param user_agent Overrides the `User-Agent`. The default identifies the +#' package and its version, which is what lets the server diagnose an old +#' client misbehaving. +#' +#' @return An object of class `cepis_client`, to pass as the `client` +#' argument of [cep_lookup()], [cep_lookup_many()] and [cep_health()]. +#' +#' @examples +#' client <- cepis_client(base_url = "http://localhost:8080", timeout = 5) +#' client$max_attempts +#' +#' @export +cepis_client <- function(base_url = DEFAULT_BASE_URL, + timeout = 10, + max_attempts = 3L, + backoff_base = 0.5, + backoff_max = 8, + total_timeout = NULL, + user_agent = NULL) { + if (!is.character(base_url) || length(base_url) != 1L || is.na(base_url)) { + stop("base_url must be a length-one character vector") + } + if (!is.numeric(timeout) || length(timeout) != 1L || is.na(timeout) || + timeout <= 0) { + stop("timeout must be a positive number of seconds") + } + if (!is.numeric(max_attempts) || length(max_attempts) != 1L || + is.na(max_attempts) || max_attempts < 1) { + stop("max_attempts must be >= 1") + } + if (!is.numeric(backoff_base) || length(backoff_base) != 1L || + is.na(backoff_base) || backoff_base < 0) { + stop("backoff_base must be >= 0") + } + if (!is.numeric(backoff_max) || length(backoff_max) != 1L || + is.na(backoff_max) || backoff_max < 0) { + stop("backoff_max must be >= 0") + } + if (!is.null(total_timeout) && + (!is.numeric(total_timeout) || length(total_timeout) != 1L || + is.na(total_timeout) || total_timeout <= 0)) { + stop("total_timeout must be NULL or a positive number of seconds") + } + if (is.null(user_agent)) { + user_agent <- paste0( + "cepis-r/", as.character(utils::packageVersion("cepis")), + " (+https://cep.is)" + ) + } + + structure( + list( + base_url = sub("/+$", "", base_url), + timeout = as.numeric(timeout), + max_attempts = as.integer(max_attempts), + backoff_base = as.numeric(backoff_base), + backoff_max = as.numeric(backoff_max), + total_timeout = total_timeout, + user_agent = user_agent + ), + class = "cepis_client" + ) +} + +#' @export +print.cepis_client <- function(x, ...) { + cat("<cepis_client> ", x$base_url, "\n", sep = "") + cat(" timeout: ", x$timeout, "s per attempt, ", x$max_attempts, + " attempt(s)\n", sep = "") + cat(" backoff: ", x$backoff_base, "s base, ", x$backoff_max, "s cap\n", + sep = "") + cat(" total timeout: ", + if (is.null(x$total_timeout)) "none" else paste0(x$total_timeout, "s"), + "\n", sep = "") + cat(" user agent: ", x$user_agent, "\n", sep = "") + invisible(x) +} + +cepis_state <- new.env(parent = emptyenv()) + +default_client <- function() { + if (is.null(cepis_state$client)) { + cepis_state$client <- cepis_client() + } + cepis_state$client +} diff --git a/R/conditions.R b/R/conditions.R new file mode 100644 index 0000000..1eebe5b --- /dev/null +++ b/R/conditions.R @@ -0,0 +1,73 @@ +#' Conditions signalled by cepis +#' +#' @description +#' Every failure raised by this package is a classed condition inheriting +#' from `cepis_error`, so one handler catches all of them: +#' +#' ```r +#' tryCatch( +#' cep_lookup(cep_from_form), +#' cepis_not_found = function(e) NULL, +#' cepis_error = function(e) reschedule() +#' ) +#' ``` +#' +#' Below `cepis_error` the split is by **the decision you have to make**, not +#' by the HTTP status that caused it: +#' +#' | class | when | does retrying help? | +#' | --- | --- | --- | +#' | `cepis_invalid_cep` | not 8 digits | no | +#' | `cepis_not_found` | well-formed, not in the database | no | +#' | `cepis_rate_limited` | 429 | yes, later | +#' | `cepis_server_error` | 5xx | yes, later | +#' | `cepis_network_error` | DNS, connection, timeout, TLS | yes, later | +#' | `cepis_invalid_response` | unintelligible response | probably not | +#' +#' That split is why `cepis_not_found` is not "the HTTP 404 condition": it is +#' also signalled with no request made at all, when the CEP is malformed +#' before it ever leaves the machine. +#' +#' Each condition carries the fields a caller needs to act on it: +#' `cepis_rate_limited` has `retry_after` (seconds the server asked for, or +#' `NULL`), `cepis_server_error` has `status`, `cepis_network_error` has +#' `url`, and `cepis_invalid_cep` has `value`, the input as it arrived. +#' +#' `cepis_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. +#' +#' @name cepis_conditions +NULL + +cepis_condition <- function(class, message, ...) { + structure( + list(message = message, call = NULL, ...), + class = c(class, "cepis_error", "error", "condition") + ) +} + +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) +} + +rate_limited_error <- function(message, retry_after = NULL) { + cepis_condition("cepis_rate_limited", message, retry_after = retry_after) +} + +server_error <- function(message, status) { + cepis_condition("cepis_server_error", message, status = status) +} + +network_error <- function(message, url) { + cepis_condition("cepis_network_error", message, url = url) +} + +invalid_response_error <- function(message) { + cepis_condition("cepis_invalid_response", message) +} diff --git a/R/http.R b/R/http.R new file mode 100644 index 0000000..6ef4511 --- /dev/null +++ b/R/http.R @@ -0,0 +1,210 @@ +elapsed <- function() { + proc.time()[["elapsed"]] +} + +MONTH_NUMBERS <- c( + Jan = 1L, Feb = 2L, Mar = 3L, Apr = 4L, May = 5L, Jun = 6L, + Jul = 7L, Aug = 8L, Sep = 9L, Oct = 10L, Nov = 11L, Dec = 12L +) + +http_date_seconds <- function(value) { + parts <- regmatches( + value, + regexec( + paste0( + "^[A-Za-z]{3}, ([0-9]{2}) ([A-Za-z]{3}) ([0-9]{4}) ", + "([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$" + ), + value + ) + )[[1L]] + if (length(parts) != 7L) { + return(NULL) + } + month <- MONTH_NUMBERS[parts[3L]] + if (is.na(month)) { + return(NULL) + } + target <- ISOdatetime( + as.integer(parts[4L]), month, as.integer(parts[2L]), + as.integer(parts[5L]), as.integer(parts[6L]), as.integer(parts[7L]), + tz = "GMT" + ) + if (is.na(target)) { + return(NULL) + } + max(0, as.numeric(difftime(target, Sys.time(), units = "secs"))) +} + +retry_after_seconds <- function(value) { + if (is.null(value) || length(value) != 1L || is.na(value)) { + return(NULL) + } + value <- trimws(value) + if (!nzchar(value)) { + return(NULL) + } + seconds <- suppressWarnings(as.numeric(value)) + if (!is.na(seconds)) { + return(max(0, seconds)) + } + http_date_seconds(value) +} + +backoff_wait <- function(client, attempt, retry_after) { + if (!is.null(retry_after)) { + return(min(retry_after, client$backoff_max)) + } + ceiling <- min(client$backoff_base * 2^(attempt - 1L), client$backoff_max) + stats::runif(1L, 0, ceiling) +} + +cepis_handle <- function(client) { + handle <- curl::new_handle() + curl::handle_setheaders( + handle, + "User-Agent" = client$user_agent, + "Accept" = "application/json, text/csv;q=0.9, */*;q=0.1", + "Accept-Encoding" = "identity" + ) + curl::handle_setopt( + handle, + connecttimeout_ms = as.integer(client$timeout * 1000), + timeout_ms = as.integer(client$timeout * 1000) + ) + handle +} + +cepis_fetch <- function(client, path, accepted_status = 200L) { + url <- paste0(client$base_url, path) + deadline <- if (is.null(client$total_timeout)) { + NULL + } else { + elapsed() + client$total_timeout + } + + last_status <- NULL + last_retry_after <- NULL + last_message <- NULL + + for (attempt in seq_len(client$max_attempts)) { + if (!is.null(deadline) && elapsed() >= deadline) { + break + } + + response <- tryCatch( + curl::curl_fetch_memory(url, handle = cepis_handle(client)), + error = function(e) e + ) + + if (inherits(response, "condition")) { + last_status <- NULL + last_retry_after <- NULL + last_message <- conditionMessage(response) + } else { + status <- as.integer(response$status_code) + headers <- curl::parse_headers_list(response$headers) + if (status %in% accepted_status || !(status %in% RETRIABLE_STATUS)) { + return(list(status = status, body = response$content, + headers = headers)) + } + last_status <- status + last_retry_after <- retry_after_seconds(headers[["retry-after"]]) + last_message <- NULL + } + + if (attempt == client$max_attempts) { + break + } + + wait <- backoff_wait(client, attempt, last_retry_after) + if (!is.null(deadline)) { + remaining <- deadline - elapsed() + if (remaining <= 0) { + break + } + wait <- min(wait, remaining) + } + Sys.sleep(wait) + } + + attempts <- paste0(" (after ", client$max_attempts, " attempt(s))") + if (identical(last_status, 429L)) { + stop(rate_limited_error( + paste0("rate limit reached at ", url, attempts), + retry_after = last_retry_after + )) + } + if (!is.null(last_status)) { + stop(server_error( + paste0("the API answered ", last_status, " at ", url, attempts), + status = last_status + )) + } + stop(network_error( + paste0("could not reach ", url, attempts, ": ", last_message), + url = url + )) +} + +decode_body <- function(body) { + if (length(body) >= 3L && + identical(as.integer(body[1:3]), c(239L, 187L, 191L))) { + body <- body[-(1:3)] + } + text <- rawToChar(body) + Encoding(text) <- "UTF-8" + text +} + +json_object <- function(body) { + text <- decode_body(body) + data <- tryCatch( + jsonlite::parse_json(text, simplifyVector = FALSE), + error = function(e) e + ) + if (inherits(data, "condition") || !is.list(data) || + (length(data) > 0L && is.null(names(data)))) { + stop(invalid_response_error(paste0( + "response is not a JSON object - something between you and the API is ", + "answering instead of it: ", substr(text, 1L, 120L) + ))) + } + data +} + +api_message <- function(body, fallback) { + 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.character(message) && length(message) == 1L && nzchar(message)) { + message + } else { + fallback + } +} + +csv_rows <- function(body) { + path <- tempfile(fileext = ".csv") + on.exit(unlink(path), add = TRUE) + writeBin(body, path) + + table <- tryCatch( + utils::read.csv( + path, + colClasses = "character", + na.strings = character(0L), + check.names = FALSE, + encoding = "UTF-8" + ), + error = function(e) e + ) + if (inherits(table, "condition")) { + stop(invalid_response_error(paste0( + "batch response is not valid CSV: ", conditionMessage(table) + ))) + } + table +} diff --git a/R/models.R b/R/models.R new file mode 100644 index 0000000..076db48 --- /dev/null +++ b/R/models.R @@ -0,0 +1,212 @@ +#' A CEP and everything the database knows about it +#' +#' @description +#' The object returned by [cep_lookup()] and by each element of +#' [cep_lookup_many()]. It is a plain list of class `cepis_address`, so +#' `address$localidade` and `str(address)` work as usual. +#' +#' **The data fields keep the API's Portuguese names.** Fields the database +#' does not have for a CEP come back as empty strings, never `NA` — that is +#' what the API returns, and changing it here would create a difference +#' between reading the JSON and reading this package. +#' +#' @section Fields: +#' \describe{ +#' \item{`cep`}{the 8 digits, always unmasked. [formatted_cep()] adds the +#' mask.} +#' \item{`logradouro`, `complemento`, `unidade`, `bairro`}{the street and +#' its qualifiers.} +#' \item{`localidade`, `uf`, `estado`, `regiao`}{municipality, state code, +#' state name, region.} +#' \item{`ibge`, `gia`, `ddd`, `siafi`}{the codes the database carries.} +#' \item{`geo`}{a [cepis_geo] object, or `NULL` when the database has no +#' coordinate for that CEP. `NULL` and not a row of `NA`s because the API +#' **omits** the whole field in that case (about 43% of CEPs) rather than +#' sending null.} +#' \item{`raw`}{the response exactly as it arrived, uninterpreted, so a +#' field the API grows before this package is updated is not lost.} +#' } +#' +#' @name cepis_address +NULL + +#' The coordinate of a CEP, when the database has one +#' +#' @description +#' The `geo` field of a [cepis_address], a plain list of class `cepis_geo`. +#' 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. +#' +#' @section Fields: +#' \describe{ +#' \item{`lat`, `lon`}{decimal degrees, WGS 84.} +#' \item{`raio_m`}{the radius, in metres, containing 90% of that CEP's +#' addresses.} +#' \item{`precisao`}{`"logradouro"` or `"localidade"` — see below.} +#' \item{`is_precise`}{`TRUE` when `precisao` is `"logradouro"`. Derived, +#' not sent by the API.} +#' } +#' +#' @section Always check precisao before measuring distance: +#' There are two kinds of point, and the difference between them is tens of +#' kilometres: +#' +#' | `precisao` | typical `raio_m` | what the point is | +#' | --- | --- | --- | +#' | `logradouro` | 110-140 m | the stretch of street: good for maps and for distance | +#' | `localidade` | 7-40 km | the municipality centroid, because the CEP *is* the whole city | +#' +#' `raio_m` exists precisely so that error cannot pass unnoticed. +#' +#' @name cepis_geo +NULL + +#' The answer of `GET /health` +#' +#' @description +#' A plain list of class `cepis_health`, returned by [cep_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. +#' +#' @section Fields: +#' \describe{ +#' \item{`status`}{`"ok"` when the API reports itself healthy.} +#' \item{`ok`}{`status == "ok"`. Derived, not sent by the API.} +#' \item{`dne`}{identifier of the fortnightly database.} +#' \item{`ceps`}{how many CEPs it holds.} +#' \item{`geo`}{how many of those carry a coordinate.} +#' \item{`base_gerada_em`}{when the base was generated, UTC.} +#' } +#' +#' @name cepis_health +NULL + +ADDRESS_FIELDS <- c( + "cep", "logradouro", "complemento", "unidade", "bairro", "localidade", + "uf", "estado", "regiao", "ibge", "gia", "ddd", "siafi" +) + +text_field <- function(data, name) { + value <- data[[name]] + if (is.null(value) || length(value) != 1L || is.na(value)) { + return("") + } + as.character(value) +} + +new_geo <- function(block) { + lat <- suppressWarnings(as.numeric(block[["lat"]])) + lon <- suppressWarnings(as.numeric(block[["lon"]])) + raio <- suppressWarnings(as.numeric(block[["raio_m"]])) + precisao <- block[["precisao"]] + + usable <- length(lat) == 1L && length(lon) == 1L && length(raio) == 1L && + length(precisao) == 1L && !anyNA(c(lat, lon, raio)) && + is.character(precisao) && !is.na(precisao) + if (!usable) { + return(NULL) + } + + structure( + list( + lat = lat, + lon = lon, + raio_m = as.integer(round(raio)), + precisao = precisao, + is_precise = identical(precisao, "logradouro") + ), + class = "cepis_geo" + ) +} + +new_address <- function(data) { + geo <- NULL + block <- data[["geo"]] + if (is.list(block)) { + geo <- new_geo(block) + } + + fields <- lapply(ADDRESS_FIELDS, function(name) text_field(data, name)) + names(fields) <- ADDRESS_FIELDS + + structure(c(fields, list(geo = geo, raw = data)), class = "cepis_address") +} + +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( + list( + status = status, + ok = identical(status, "ok"), + dne = text_field(data, "dne"), + ceps = count("ceps"), + geo = count("geo"), + base_gerada_em = text_field(data, "base_gerada_em") + ), + class = "cepis_health" + ) +} + +#' Add the mask to a CEP +#' +#' @description +#' `"01001000"` becomes `"01001-000"`. The API always answers unmasked; +#' formatting is presentation, and it lives here so it does not become a +#' repeated `paste0()` in every project that uses this package. +#' +#' @param x A [cepis_address] or a character vector of CEPs. +#' +#' @return A character vector of masked CEPs. A value that is not 8 digits is +#' returned unchanged, because this is a formatting helper and not a +#' validator — [normalize_cep()] is the validator. +#' +#' @examples +#' formatted_cep("01001000") +#' +#' @export +formatted_cep <- function(x) { + if (inherits(x, "cepis_address")) { + x <- x$cep + } + x <- as.character(x) + masked <- paste0(substr(x, 1L, 5L), "-", substr(x, 6L, 8L)) + ifelse(grepl("^[0-9]{8}$", x), masked, x) +} + +#' @export +print.cepis_address <- function(x, ...) { + cat("<cepis_address>", formatted_cep(x$cep), "\n") + street <- paste(c(x$logradouro, x$complemento, x$bairro), collapse = ", ") + cat(" ", street, "\n", sep = "") + cat(" ", x$localidade, "/", x$uf, " (IBGE ", x$ibge, ", DDD ", x$ddd, + ")\n", sep = "") + if (is.null(x$geo)) { + cat(" geo: none\n") + } else { + cat(" geo: ", x$geo$lat, ", ", x$geo$lon, " (raio ", x$geo$raio_m, + " m, ", x$geo$precisao, ")\n", sep = "") + } + invisible(x) +} + +#' @export +print.cepis_geo <- function(x, ...) { + cat("<cepis_geo> ", x$lat, ", ", x$lon, " (raio ", x$raio_m, " m, ", + x$precisao, ")\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 = "") + cat(" ceps: ", x$ceps, ", with geo: ", x$geo, "\n", sep = "") + invisible(x) +} diff --git a/R/normalize.R b/R/normalize.R new file mode 100644 index 0000000..e54c0ef --- /dev/null +++ b/R/normalize.R @@ -0,0 +1,43 @@ +#' Return the 8 digits of a CEP, accepting the mask +#' +#' @description +#' `"01001-000"`, `"01001000"`, `"01001.000"` and `" 01001-000 "` all become +#' `"01001000"`. +#' +#' This runs **before** any request: a 7-digit CEP is the caller's bug, and +#' spending a round trip for the server to say so wastes both sides' time. +#' +#' @param value A length-one character vector. Numbers are rejected on +#' purpose: `1001000` has already lost the leading zero that makes +#' `"01001000"` a CEP. Convert with `sprintf("%08d", x)` first. +#' +#' @return The 8 digits, as a length-one character vector. +#' +#' @section Conditions: +#' Signals [cepis_invalid_cep][cepis_conditions] unless exactly 8 digits +#' remain. +#' +#' @examples +#' normalize_cep("01001-000") +#' +#' @export +normalize_cep <- function(value) { + if (!is.character(value) || length(value) != 1L || is.na(value)) { + stop(invalid_cep_error( + paste0( + "CEP must be a length-one character vector, got ", + class(value)[1L], " of length ", length(value) + ), + value = value + )) + } + + cleaned <- gsub("[-. ]", "", trimws(value)) + if (!grepl("^[0-9]{8}$", cleaned)) { + stop(invalid_cep_error( + paste0("CEP must be 8 digits, got \"", value, "\""), + value = value + )) + } + cleaned +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..64baf53 --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# cepis + +R client for the [public CEP API at cep.is](https://cep.is). Official +Correios data (DNE), refreshed every two weeks. + +```r +library(cepis) + +address <- cep_lookup("01001-000") +cat(address$logradouro, address$localidade, address$uf) +#> Praça da Sé São Paulo SP +``` + +## Installation + +```r +remotes::install_git("https://git.cep.is/r.git") +``` + +Requires R 4.0 or newer, plus two packages: **curl**, because base R cannot +report a response status code and this client has to tell a 404 from a 400 +from a 5xx, and **jsonlite** to read the JSON. Both are the ecosystem +standard and neither pulls a stack behind it. CSV is parsed with +`utils::read.csv()`. + +## Usage + +### A single lookup + +`cep_lookup()` accepts the CEP masked or unmasked and returns a +`cepis_address`, a plain list: + +```r +a <- cep_lookup("01310-100") + +a$logradouro # "Avenida Paulista" +a$localidade # "São Paulo" +a$uf # "SP" +a$ibge # "3550308" +a$cep # "01310100" (always unmasked) +formatted_cep(a) # "01310-100" +``` + +Fields the database does not have for that CEP come back as empty strings, +never `NA`. `a$raw` holds the response as it arrived, including any field the +API grew after this release. + +### Coordinates + +About 57% of CEPs have a coordinate. When they do not, `geo` is `NULL`: + +```r +a <- cep_lookup("01310100") + +if (!is.null(a$geo)) { + a$geo$lat # -23.567202 + a$geo$lon # -46.649659 + a$geo$raio_m # 1084 + a$geo$precisao # "logradouro" +} +``` + +**Check `precisao` before computing distance.** There are two kinds of point, +and the difference between them is tens of kilometres: + +| `precisao` | typical radius | what the point is | +| --- | --- | --- | +| `logradouro` | 110–140 m | the stretch of street — good for maps and distance | +| `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city | + +`a$geo$is_precise` is the shorthand for `precisao == "logradouro"`: + +```r +if (!is.null(a$geo) && a$geo$is_precise) { + cost <- shipping_from(a$geo$lat, a$geo$lon) +} else { + cost <- shipping_from_municipality(a$ibge) +} +``` + +### Many CEPs at once + +Do not loop over `cep_lookup()`. Use `cep_lookup_many()`, which resolves up +to 500 CEPs per request: + +```r +ceps <- c("01001000", "99999999", "20040020") +result <- cep_lookup_many(ceps) + +for (i in seq_along(ceps)) { + if (is.null(result[[i]])) { + cat(ceps[i], "not found\n") + } else { + cat(ceps[i], result[[i]]$localidade, "\n") + } +} +``` + +The returned list has **the same length and the same order** as the input. A +CEP that does not exist, or is malformed, is `NULL` at its position — it does +not vanish, and it does not raise. A list rather than a `data.frame` because +`NULL` is the only unambiguous "no such CEP" in R: a row of `NA`s reads as an +address with unknown fields, which is a different statement. + +Vectors longer than 500 are chunked automatically. + +> **Batches carry no coordinate.** The endpoint behind them is CSV, and the +> CSV has no `geo` column. Every address from `cep_lookup_many()` has +> `geo = NULL`, including for CEPs that do have one. Need `geo`? Use +> `cep_lookup()`, one at a time. + +### API health + +Useful for two things: checking the API answers, and knowing **which version +of the database** is live: + +```r +h <- cep_health() +h$ok # TRUE +h$dne # "26071" identifier of the fortnightly database +h$ceps # 1596168 +h$base_gerada_em # "2026-07-24T03:48:28Z" +``` + +An API reporting itself degraded does not raise — that arrives in `h$status`. + +## Configuration + +For anything beyond the defaults, build a client with `cepis_client()` and +pass it along. It holds configuration only, keeps no state between calls and +is safe to reuse: + +```r +client <- cepis_client( + base_url = "https://cep.is", # point at another instance + timeout = 10, # seconds for one attempt + max_attempts = 3L, # counting the first; 1 disables retrying + backoff_base = 0.5, # base of the exponential wait + backoff_max = 8, # cap on each wait + total_timeout = NULL # cap on the whole call, attempts included +) + +address <- cep_lookup("01001000", client) +``` + +Retries cover 429, 5xx and network failures, with exponential backoff and +jitter; 400 and 404 are never retried. See `?cepis_client` for the details of +each parameter. + +Omitting `client` uses a shared default one, built with the defaults above. + +### Pointing at a local instance + +`base_url` is the only parameter that usually changes between environments. +The API repo brings a local instance up with `just up`: + +```r +client <- cepis_client(base_url = "http://localhost:8080") +``` + +## Errors + +Failures are classed conditions. Everything inherits `cepis_error` (and +`error`), so one handler catches all of them. Below that, the split is by +**the decision you have to make**, not by the HTTP status that caused it: + +| class | when | does retrying help? | +| --- | --- | --- | +| `cepis_invalid_cep` | not 8 digits | no | +| `cepis_not_found` | well-formed, not in the database | 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 | +| `cepis_invalid_response` | unintelligible response | probably not | + +```r +tryCatch( + cep_lookup(cep_from_form), + cepis_not_found = function(e) NULL, # ask the user for another CEP + cepis_error = function(e) reschedule(e) # transient failure: retry later +) +``` + +`cepis_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. + +## Development + +Everything runs through `make`: + +```sh +make # list the targets +make test # install into check/lib and run the suite +make lint # parse + codetools, touches nothing +make document # regenerate man/ and NAMESPACE with roxygen2 +make build # build the source tarball +make check # lint, then test, then build +make check-cran # build, then R CMD check the tarball +``` + +The suite stands up a real HTTP server on loopback and points `base_url` at +it, so no test touches the network. + +## Licence + +ISC. See [LICENSE](LICENSE). diff --git a/man/cep_health.Rd b/man/cep_health.Rd new file mode 100644 index 0000000..7fdfc1b --- /dev/null +++ b/man/cep_health.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/api.R +\name{cep_health} +\alias{cep_health} +\title{Ask the API how it is, and which database it is serving} +\usage{ +cep_health(client = default_client()) +} +\arguments{ +\item{client}{A \code{\link[=cepis_client]{cepis_client()}}. Defaults to a shared client built with +the default configuration.} +} +\value{ +A \link{cepis_health}. +} +\description{ +Useful for two things: checking that the API answers, and knowing which +version of the DNE database is live. The database changes every two weeks. +} +\section{Conditions}{ + +An API reporting itself degraded is not a failure here — that arrives in +\code{status}, and checking health is exactly when you want the diagnosis +rather than an error. Network failures still signal \code{cepis_network_error}. +} + +\examples{ +\donttest{ +health <- cep_health() +health$ok +health$dne +} + +} diff --git a/man/cep_lookup.Rd b/man/cep_lookup.Rd new file mode 100644 index 0000000..3c22a1e --- /dev/null +++ b/man/cep_lookup.Rd @@ -0,0 +1,46 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/api.R +\name{cep_lookup} +\alias{cep_lookup} +\title{Look up a single CEP} +\usage{ +cep_lookup(cep, client = default_client()) +} +\arguments{ +\item{cep}{A length-one character vector: \code{"01001-000"} or \code{"01001000"}.} + +\item{client}{A \code{\link[=cepis_client]{cepis_client()}}. Defaults to a shared client built with +the default configuration.} +} +\value{ +A \link{cepis_address}. +} +\description{ +Accepts the CEP masked or unmasked and returns everything the DNE database +has for it, including the coordinate when there is one. +} +\section{Conditions}{ + +\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_rate_limited}}{429 on every attempt.} +\item{\code{cepis_server_error}}{5xx on every attempt.} +\item{\code{cepis_network_error}}{the request never completed.} +\item{\code{cepis_invalid_response}}{a success status with an unreadable +body.} +} + +All of them inherit \code{cepis_error}; see \link{cepis_conditions}. +} + +\examples{ +\donttest{ +address <- cep_lookup("01001-000") +address$localidade +if (!is.null(address$geo) && address$geo$is_precise) { + c(address$geo$lat, address$geo$lon) +} +} + +} diff --git a/man/cep_lookup_many.Rd b/man/cep_lookup_many.Rd new file mode 100644 index 0000000..9362649 --- /dev/null +++ b/man/cep_lookup_many.Rd @@ -0,0 +1,51 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/api.R +\name{cep_lookup_many} +\alias{cep_lookup_many} +\title{Look up many CEPs in one request} +\usage{ +cep_lookup_many(ceps, client = default_client()) +} +\arguments{ +\item{ceps}{A character vector of CEPs, masked or unmasked.} + +\item{client}{A \code{\link[=cepis_client]{cepis_client()}}. Defaults to a shared client built with +the default configuration.} +} +\value{ +A list of \strong{the same length and in the same order} as \code{ceps}. +Each element is a \link{cepis_address}, or \code{NULL} where the CEP does not +exist or is malformed — it does not vanish from the list, and it does not +signal a condition. That is the right difference from \code{\link[=cep_lookup]{cep_lookup()}}: in +a batch of a thousand, one bad CEP must not cost the other nine hundred +and ninety-nine. Use \code{\link[=normalize_cep]{normalize_cep()}} beforehand if you need to know +\emph{which} input was malformed. + +A list rather than a \code{data.frame} because \code{NULL} is the only unambiguous +"no such CEP" in R: a row of \code{NA}s reads as an address with unknown +fields, which is a different statement, and an empty row cannot be told +apart from a CEP the database knows only by number. Rectangle it with +\code{do.call(rbind, lapply(result, function(a) as.data.frame(a[1:13])))} when +a \code{data.frame} is what you want. +} +\description{ +Resolves up to 500 CEPs per request, chunking longer inputs automatically. +Do not loop over \code{\link[=cep_lookup]{cep_lookup()}}: a loop of 500 calls pays 500 TLS +handshakes, the batch pays one. +} +\section{Batches carry no coordinate}{ + +The endpoint behind them is CSV, and the CSV has no \code{geo} column. Every +address returned here has \code{geo = NULL}, including for CEPs that do have +one. Need \code{geo}? Use \code{\link[=cep_lookup]{cep_lookup()}}, one at a time. +} + +\examples{ +\donttest{ +ceps <- c("01001000", "99999999", "20040020") +result <- cep_lookup_many(ceps) +vapply(result, function(a) if (is.null(a)) NA_character_ else a$localidade, + character(1)) +} + +} diff --git a/man/cepis-package.Rd b/man/cepis-package.Rd new file mode 100644 index 0000000..d182e04 --- /dev/null +++ b/man/cepis-package.Rd @@ -0,0 +1,38 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cepis-package.R +\docType{package} +\name{cepis-package} +\alias{cepis} +\alias{cepis-package} +\title{cepis: client for the public CEP API at cep.is} +\description{ +Resolves Brazilian postal codes against \url{https://cep.is}. Official +Correios data (DNE), refreshed every two weeks. + +\if{html}{\out{<div class="sourceCode r">}}\preformatted{address <- cep_lookup("01001-000") +address$logradouro # "Praca da Se" +address$localidade # "Sao Paulo" +}\if{html}{\out{</div>}} + +The API surface is English, but the \strong{data fields keep the API's own +Portuguese names} (\code{logradouro}, \code{bairro}, \code{localidade}, \code{uf}). What you +read in the JSON at \url{https://cep.is/01001000} is what you read on the +returned object, with no translation layer in between. + +Start at \code{\link[=cep_lookup]{cep_lookup()}} for one CEP, \code{\link[=cep_lookup_many]{cep_lookup_many()}} for many, +\code{\link[=cep_health]{cep_health()}} for the API's status and \code{\link[=cepis_client]{cepis_client()}} for anything +beyond the default configuration. Failures are classed conditions, +described in \link{cepis_conditions}. +} +\seealso{ +Useful links: +\itemize{ + \item \url{https://cep.is} +} + +} +\author{ +\strong{Maintainer}: Murilo Ijanc \email{[email protected]} + +} +\keyword{internal} diff --git a/man/cepis_address.Rd b/man/cepis_address.Rd new file mode 100644 index 0000000..6fc0b89 --- /dev/null +++ b/man/cepis_address.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/models.R +\name{cepis_address} +\alias{cepis_address} +\title{A CEP and everything the database knows about it} +\description{ +The object returned by \code{\link[=cep_lookup]{cep_lookup()}} and by each element of +\code{\link[=cep_lookup_many]{cep_lookup_many()}}. It is a plain list of class \code{cepis_address}, so +\code{address$localidade} and \code{str(address)} work as usual. + +\strong{The data fields keep the API's Portuguese names.} Fields the database +does not have for a CEP come back as empty strings, never \code{NA} — that is +what the API returns, and changing it here would create a difference +between reading the JSON and reading this package. +} +\section{Fields}{ + +\describe{ +\item{\code{cep}}{the 8 digits, always unmasked. \code{\link[=formatted_cep]{formatted_cep()}} adds the +mask.} +\item{\code{logradouro}, \code{complemento}, \code{unidade}, \code{bairro}}{the street and +its qualifiers.} +\item{\code{localidade}, \code{uf}, \code{estado}, \code{regiao}}{municipality, state code, +state name, region.} +\item{\code{ibge}, \code{gia}, \code{ddd}, \code{siafi}}{the codes the database carries.} +\item{\code{geo}}{a \link{cepis_geo} object, or \code{NULL} when the database has no +coordinate for that CEP. \code{NULL} and not a row of \code{NA}s because the API +\strong{omits} the whole field in that case (about 43\% of CEPs) rather than +sending null.} +\item{\code{raw}}{the response exactly as it arrived, uninterpreted, so a +field the API grows before this package is updated is not lost.} +} +} + diff --git a/man/cepis_client.Rd b/man/cepis_client.Rd new file mode 100644 index 0000000..f8644e7 --- /dev/null +++ b/man/cepis_client.Rd @@ -0,0 +1,64 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/client.R +\name{cepis_client} +\alias{cepis_client} +\title{Build a client for the cep.is API} +\usage{ +cepis_client( + base_url = DEFAULT_BASE_URL, + timeout = 10, + max_attempts = 3L, + backoff_base = 0.5, + backoff_max = 8, + total_timeout = NULL, + user_agent = NULL +) +} +\arguments{ +\item{base_url}{Where the API lives. Point it at a local instance (the API +repo brings one up on \verb{http://localhost:8080} with \verb{just up}) or at a +test server. It is the only parameter that usually changes between +environments. Trailing slashes are stripped, so it cannot produce +doubled separators that some proxies normalise and others 404.} + +\item{timeout}{Seconds allowed for \strong{one attempt}, connection and +transfer together. It is not a cap on the whole call, since a call may +make several attempts; that cap is \code{total_timeout}.} + +\item{max_attempts}{How many attempts in total, counting the first. \code{1} +disables retrying. The default of \code{3} covers the common case (one +transient failure) without turning a real outage into a long wait.} + +\item{backoff_base}{Base of the exponential wait, in seconds. The actual +wait is drawn from \verb{[0, base * 2^(n - 1)]} — \emph{full jitter}. The +randomness is not decoration: without it, N clients that fail together +come back together and reproduce the spike that took the service down.} + +\item{backoff_max}{Cap on each individual wait, in seconds. It applies to +a server-sent \code{Retry-After} too, or a \code{Retry-After: 3600} from a +misconfigured proxy would park the session for an hour.} + +\item{total_timeout}{Cap on the whole call, attempts and waits included. +\code{NULL} leaves \code{max_attempts} as the only bound. Set it in code serving a +web request: without it the worst case is \code{max_attempts * timeout} plus +the sum of the backoffs.} + +\item{user_agent}{Overrides the \code{User-Agent}. The default identifies the +package and its version, which is what lets the server diagnose an old +client misbehaving.} +} +\value{ +An object of class \code{cepis_client}, to pass as the \code{client} +argument of \code{\link[=cep_lookup]{cep_lookup()}}, \code{\link[=cep_lookup_many]{cep_lookup_many()}} and \code{\link[=cep_health]{cep_health()}}. +} +\description{ +Holds the configuration and nothing else: no connection, no state between +calls. Build one and reuse it, or skip it entirely — \code{\link[=cep_lookup]{cep_lookup()}}, +\code{\link[=cep_lookup_many]{cep_lookup_many()}} and \code{\link[=cep_health]{cep_health()}} fall back to a shared default +client built with these defaults. +} +\examples{ +client <- cepis_client(base_url = "http://localhost:8080", timeout = 5) +client$max_attempts + +} diff --git a/man/cepis_conditions.Rd b/man/cepis_conditions.Rd new file mode 100644 index 0000000..4def4ff --- /dev/null +++ b/man/cepis_conditions.Rd @@ -0,0 +1,42 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/conditions.R +\name{cepis_conditions} +\alias{cepis_conditions} +\title{Conditions signalled by cepis} +\description{ +Every failure raised by this package is a classed condition inheriting +from \code{cepis_error}, so one handler catches all of them: + +\if{html}{\out{<div class="sourceCode r">}}\preformatted{tryCatch( + cep_lookup(cep_from_form), + cepis_not_found = function(e) NULL, + cepis_error = function(e) reschedule() +) +}\if{html}{\out{</div>}} + +Below \code{cepis_error} the split is by \strong{the decision you have to make}, not +by the HTTP status that caused it:\tabular{lll}{ + class \tab when \tab does retrying help? \cr + \code{cepis_invalid_cep} \tab not 8 digits \tab no \cr + \code{cepis_not_found} \tab well-formed, not in the database \tab no \cr + \code{cepis_rate_limited} \tab 429 \tab yes, later \cr + \code{cepis_server_error} \tab 5xx \tab yes, later \cr + \code{cepis_network_error} \tab DNS, connection, timeout, TLS \tab yes, later \cr + \code{cepis_invalid_response} \tab unintelligible response \tab probably not \cr +} + + +That split is why \code{cepis_not_found} is not "the HTTP 404 condition": it is +also signalled with no request made at all, when the CEP is malformed +before it ever leaves the machine. + +Each condition carries the fields a caller needs to act on it: +\code{cepis_rate_limited} has \code{retry_after} (seconds the server asked for, or +\code{NULL}), \code{cepis_server_error} has \code{status}, \code{cepis_network_error} has +\code{url}, and \code{cepis_invalid_cep} has \code{value}, the input as it arrived. + +\code{cepis_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/man/cepis_geo.Rd b/man/cepis_geo.Rd new file mode 100644 index 0000000..3c92979 --- /dev/null +++ b/man/cepis_geo.Rd @@ -0,0 +1,36 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/models.R +\name{cepis_geo} +\alias{cepis_geo} +\title{The coordinate of a CEP, when the database has one} +\description{ +The \code{geo} field of a \link{cepis_address}, a plain list of class \code{cepis_geo}. +The point is the \strong{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. +} +\section{Fields}{ + +\describe{ +\item{\code{lat}, \code{lon}}{decimal degrees, WGS 84.} +\item{\code{raio_m}}{the radius, in metres, containing 90\% of that CEP's +addresses.} +\item{\code{precisao}}{\code{"logradouro"} or \code{"localidade"} — see below.} +\item{\code{is_precise}}{\code{TRUE} when \code{precisao} is \code{"logradouro"}. Derived, +not sent by the API.} +} +} + +\section{Always check precisao before measuring distance}{ + +There are two kinds of point, and the difference between them is tens of +kilometres:\tabular{lll}{ + \code{precisao} \tab typical \code{raio_m} \tab what the point is \cr + \code{logradouro} \tab 110-140 m \tab the stretch of street: good for maps and for distance \cr + \code{localidade} \tab 7-40 km \tab the municipality centroid, because the CEP \emph{is} the whole city \cr +} + + +\code{raio_m} exists precisely so that error cannot pass unnoticed. +} + diff --git a/man/cepis_health.Rd b/man/cepis_health.Rd new file mode 100644 index 0000000..dc00d8b --- /dev/null +++ b/man/cepis_health.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/models.R +\name{cepis_health} +\alias{cepis_health} +\title{The answer of \code{GET /health}} +\description{ +A plain list of class \code{cepis_health}, returned by \code{\link[=cep_health]{cep_health()}}. Good for +two things: checking that the API answers, and knowing \strong{which version of +the database} is live. The DNE database changes every two weeks; \code{dne} +identifies it. +} +\section{Fields}{ + +\describe{ +\item{\code{status}}{\code{"ok"} when the API reports itself healthy.} +\item{\code{ok}}{\code{status == "ok"}. Derived, not sent by the API.} +\item{\code{dne}}{identifier of the fortnightly database.} +\item{\code{ceps}}{how many CEPs it holds.} +\item{\code{geo}}{how many of those carry a coordinate.} +\item{\code{base_gerada_em}}{when the base was generated, UTC.} +} +} + diff --git a/man/formatted_cep.Rd b/man/formatted_cep.Rd new file mode 100644 index 0000000..7f77b4e --- /dev/null +++ b/man/formatted_cep.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/models.R +\name{formatted_cep} +\alias{formatted_cep} +\title{Add the mask to a CEP} +\usage{ +formatted_cep(x) +} +\arguments{ +\item{x}{A \link{cepis_address} or a character vector of CEPs.} +} +\value{ +A character vector of masked CEPs. A value that is not 8 digits is +returned unchanged, because this is a formatting helper and not a +validator — \code{\link[=normalize_cep]{normalize_cep()}} is the validator. +} +\description{ +\code{"01001000"} becomes \code{"01001-000"}. The API always answers unmasked; +formatting is presentation, and it lives here so it does not become a +repeated \code{paste0()} in every project that uses this package. +} +\examples{ +formatted_cep("01001000") + +} diff --git a/man/normalize_cep.Rd b/man/normalize_cep.Rd new file mode 100644 index 0000000..6f9b462 --- /dev/null +++ b/man/normalize_cep.Rd @@ -0,0 +1,33 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/normalize.R +\name{normalize_cep} +\alias{normalize_cep} +\title{Return the 8 digits of a CEP, accepting the mask} +\usage{ +normalize_cep(value) +} +\arguments{ +\item{value}{A length-one character vector. Numbers are rejected on +purpose: \code{1001000} has already lost the leading zero that makes +\code{"01001000"} a CEP. Convert with \code{sprintf("\%08d", x)} first.} +} +\value{ +The 8 digits, as a length-one character vector. +} +\description{ +\code{"01001-000"}, \code{"01001000"}, \code{"01001.000"} and \code{" 01001-000 "} all become +\code{"01001000"}. + +This runs \strong{before} any request: a 7-digit CEP is the caller's bug, and +spending a round trip for the server to say so wastes both sides' time. +} +\section{Conditions}{ + +Signals \link[=cepis_conditions]{cepis_invalid_cep} unless exactly 8 digits +remain. +} + +\examples{ +normalize_cep("01001-000") + +} diff --git a/tests/helper/server.R b/tests/helper/server.R new file mode 100644 index 0000000..48f9a39 --- /dev/null +++ b/tests/helper/server.R @@ -0,0 +1,114 @@ +args <- commandArgs(trailingOnly = TRUE) +routes <- readRDS(args[[1L]]) +logfile <- args[[2L]] +readyfile <- args[[3L]] + +server <- NULL +port <- NA_integer_ +for (candidate in sample(20000:60000, 500L)) { + server <- tryCatch(serverSocket(candidate), error = function(e) NULL) + if (!is.null(server)) { + port <- candidate + break + } +} +if (is.null(server)) { + stop("no free port for the fake server") +} + +state <- new.env(parent = emptyenv()) +state$queues <- lapply(routes, function(route) route$responses) + +pick <- function(path) { + index <- NA_integer_ + for (i in seq_along(routes)) { + if (identical(routes[[i]]$match, path)) { + index <- i + break + } + } + if (is.na(index)) { + for (i in seq_along(routes)) { + if (startsWith(path, routes[[i]]$match)) { + index <- i + break + } + } + } + if (is.na(index)) { + return(list( + status = 404L, + body = "{\"erro\":\"cep_nao_encontrado\"}", + headers = list() + )) + } + queue <- state$queues[[index]] + if (length(queue) > 1L) { + state$queues[[index]] <- queue[-1L] + } + queue[[1L]] +} + +send <- function(connection, response) { + body <- charToRaw(enc2utf8(response$body)) + head <- paste0("HTTP/1.1 ", response$status, " Response\r\n") + for (name in names(response$headers)) { + head <- paste0(head, name, ": ", response$headers[[name]], "\r\n") + } + head <- paste0( + head, + "Content-Type: text/plain; charset=utf-8\r\n", + "Content-Length: ", length(body), "\r\n", + "Connection: close\r\n\r\n" + ) + try( + { + writeBin(c(charToRaw(head), body), connection) + flush(connection) + }, + silent = TRUE + ) +} + +pending <- paste0(readyfile, ".tmp") +writeLines(as.character(port), pending) +file.rename(pending, readyfile) + +repeat { + if (!socketSelect(list(server), timeout = 60)) { + break + } + connection <- socketAccept(server, blocking = TRUE, open = "r+b", + timeout = 10) + path <- NA_character_ + repeat { + line <- readLines(connection, n = 1L, warn = FALSE) + if (length(line) == 0L) { + break + } + line <- sub("\r$", "", line) + if (!nzchar(line)) { + break + } + if (is.na(path)) { + path <- strsplit(line, " ", fixed = TRUE)[[1L]][2L] + } + } + + stopping <- FALSE + if (!is.na(path)) { + cat(path, "\n", sep = "", file = logfile, append = TRUE) + if (identical(path, "/__shutdown__")) { + stopping <- TRUE + send(connection, list(status = 200L, body = "bye", headers = list())) + } else { + send(connection, pick(path)) + } + } + try(close(connection), silent = TRUE) + if (stopping) { + break + } +} + +close(server) diff --git a/tests/helper/utils.R b/tests/helper/utils.R new file mode 100644 index 0000000..3c24a2f --- /dev/null +++ b/tests/helper/utils.R @@ -0,0 +1,127 @@ +SERVER_SCRIPT <- normalizePath(file.path("helper", "server.R"), mustWork = TRUE) + +SE_ADDRESS <- list( + cep = "01001000", + logradouro = "Pra\u00e7a da S\u00e9", + complemento = "lado \u00edmpar", + unidade = "", + bairro = "S\u00e9", + localidade = "S\u00e3o Paulo", + uf = "SP", + estado = "S\u00e3o Paulo", + regiao = "Sudeste", + ibge = "3550308", + gia = "1004", + ddd = "11", + siafi = "7107" +) + +CSV_HEADER <- paste0( + "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,", + "ibge,gia,ddd,siafi" +) + +json <- function(x) { + as.character(jsonlite::toJSON(x, auto_unbox = TRUE)) +} + +csv <- function(rows) { + paste0(CSV_HEADER, "\n", paste(rows, collapse = "\n"), "\n") +} + +response <- function(status, body, headers = list()) { + list(status = status, body = body, headers = headers) +} + +route <- function(match, ...) { + list(match = match, responses = list(...)) +} + +start_server <- function(routes) { + directory <- tempfile("cepis-fake-server-") + dir.create(directory) + routes_path <- file.path(directory, "routes.rds") + saveRDS(routes, routes_path) + logfile <- file.path(directory, "requests.log") + file.create(logfile) + readyfile <- file.path(directory, "ready") + + system2( + file.path(R.home("bin"), "Rscript"), + shQuote(c(SERVER_SCRIPT, routes_path, logfile, readyfile)), + wait = FALSE, + stdout = file.path(directory, "stdout"), + stderr = file.path(directory, "stderr") + ) + + deadline <- Sys.time() + 30 + while (!file.exists(readyfile) && Sys.time() < deadline) { + Sys.sleep(0.02) + } + if (!file.exists(readyfile)) { + stop( + "the fake server did not start: ", + paste(readLines(file.path(directory, "stderr"), warn = FALSE), + collapse = " ") + ) + } + + port <- readLines(readyfile, warn = FALSE)[[1L]] + list( + url = paste0("http://127.0.0.1:", port), + directory = directory, + logfile = logfile + ) +} + +stop_server <- function(server) { + try( + curl::curl_fetch_memory(paste0(server$url, "/__shutdown__")), + silent = TRUE + ) + unlink(server$directory, recursive = TRUE) +} + +calls <- function(server, prefix = "") { + lines <- readLines(server$logfile, warn = FALSE) + sum(startsWith(lines, prefix)) +} + +with_server <- function(routes, body) { + server <- start_server(routes) + on.exit(stop_server(server), add = TRUE) + client <- cepis_client( + base_url = server$url, + timeout = 5, + backoff_base = 0.001, + backoff_max = 0.01 + ) + body(client, server) +} + +caught <- function(expr) { + tryCatch( + { + force(expr) + NULL + }, + error = function(condition) condition + ) +} + +check <- function(label, condition) { + if (!isTRUE(condition)) { + stop("FAIL: ", label, call. = FALSE) + } + cat("ok - ", label, "\n", sep = "") + invisible(TRUE) +} + +check_condition <- function(label, expr, class) { + condition <- caught(expr) + check( + label, + inherits(condition, class) && inherits(condition, "cepis_error") + ) + invisible(condition) +} diff --git a/tests/test-batch.R b/tests/test-batch.R new file mode 100644 index 0000000..6b28bcc --- /dev/null +++ b/tests/test-batch.R @@ -0,0 +1,78 @@ +library(cepis) +source(file.path("helper", "utils.R")) + +rows <- c( + paste0( + "01001000,Pra\u00e7a da S\u00e9,lado \u00edmpar,,S\u00e9,S\u00e3o Paulo,", + "SP,S\u00e3o Paulo,Sudeste,3550308,1004,11,7107" + ), + "99999999,,,,,,,,,,,,", + paste0( + "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,", + "Sudeste,3304557,,21,6001" + ) +) + +with_server( + list(route("/csv?", response(200L, csv(rows)))), + function(client, server) { + result <- cep_lookup_many(c("01001000", "99999999", "20040020"), client) + check("the batch is as long as the input", length(result) == 3L) + check("the first CEP resolved", identical(result[[1L]]$localidade, "S\u00e3o Paulo")) + check("a missing CEP is NULL at its position", is.null(result[[2L]])) + check("the third CEP kept its position", identical(result[[3L]]$uf, "RJ")) + check("a batch address carries no coordinate", is.null(result[[3L]]$geo)) + check("one request served the whole batch", calls(server, "/csv?") == 1L) + } +) + +quoted <- paste0( + '01001000,"Rua Um, Dois",,,Centro,S\u00e3o Paulo,SP,S\u00e3o Paulo,', + "Sudeste,1,,11,1" +) + +with_server( + list(route("/csv?", response(200L, csv(quoted)))), + function(client, server) { + result <- cep_lookup_many("01001000", client) + check( + "a quoted field containing a comma is parsed as one value", + identical(result[[1L]]$logradouro, "Rua Um, Dois") + ) + } +) + +with_server( + list(route("/csv?", response(200L, csv(character(0L))))), + function(client, server) { + check("an empty input returns an empty list", identical(cep_lookup_many(character(0L), client), list())) + check("and makes no request at all", calls(server) == 0L) + } +) + +many <- sprintf("%08d", seq_len(501L) - 1L) +chunk_row <- function(cep) paste0(cep, ",,,,,X,SP,S\u00e3o Paulo,Sudeste,1,,11,1") + +with_server( + list(route( + "/csv?", + response(200L, csv(vapply(many[1:500], chunk_row, character(1L)))), + response(200L, csv(vapply(many[501], chunk_row, character(1L)))) + )), + function(client, server) { + result <- cep_lookup_many(many, client) + check("501 CEPs are split into two requests", calls(server, "/csv?") == 2L) + check("the result still has one entry per input", length(result) == 501L) + check("the last entry belongs to the last CEP", identical(result[[501L]]$cep, "00000500")) + } +) + +with_server( + list(route("/csv?", response(200L, csv("01001000,,,,,S\u00e3o Paulo,SP,S\u00e3o Paulo,Sudeste,1,,11,1")))), + function(client, server) { + result <- cep_lookup_many(c("01001000", "20040020"), client) + check("a truncated response is padded, never shifted", length(result) == 2L) + check("the missing row became NULL", is.null(result[[2L]])) + check("the row that arrived kept its position", identical(result[[1L]]$cep, "01001000")) + } +) diff --git a/tests/test-config.R b/tests/test-config.R new file mode 100644 index 0000000..57e3b3b --- /dev/null +++ b/tests/test-config.R @@ -0,0 +1,27 @@ +library(cepis) +source(file.path("helper", "utils.R")) + +check( + "a trailing slash is stripped from base_url", + identical(cepis_client(base_url = "http://x/")$base_url, "http://x") +) +check( + "the user agent identifies the library", + grepl("cepis-r/", cepis_client()$user_agent, fixed = TRUE) +) +check( + "max_attempts below one is rejected", + inherits(caught(cepis_client(max_attempts = 0L)), "error") +) +check( + "a timeout of zero is rejected", + inherits(caught(cepis_client(timeout = 0)), "error") +) +check( + "a total_timeout of zero is rejected", + inherits(caught(cepis_client(total_timeout = 0)), "error") +) +check( + "the default base_url is the public API", + identical(cepis_client()$base_url, "https://cep.is") +) diff --git a/tests/test-health.R b/tests/test-health.R new file mode 100644 index 0000000..21007af --- /dev/null +++ b/tests/test-health.R @@ -0,0 +1,33 @@ +library(cepis) +source(file.path("helper", "utils.R")) + +healthy <- list( + status = "ok", + dne = "26071", + ceps = 1596168L, + geo = 894489L, + base_gerada_em = "2026-07-24T03:48:28Z" +) + +with_server( + list(route("/health", response(200L, json(healthy)))), + function(client, server) { + health <- cep_health(client) + check("health reports itself ok", isTRUE(health$ok)) + check("it names the database version", identical(health$dne, "26071")) + check("it counts the CEPs", identical(health$ceps, 1596168L)) + check("it counts the ones with a coordinate", identical(health$geo, 894489L)) + } +) + +degraded <- list(status = "degradado", dne = "", ceps = 0L, geo = 0L) + +with_server( + list(route("/health", response(503L, json(degraded)))), + function(client, server) { + health <- cep_health(client) + check("a degraded API is a diagnosis, not an error", isFALSE(health$ok)) + check("the status is readable", identical(health$status, "degradado")) + check("a 503 at /health is not retried", calls(server, "/health") == 1L) + } +) diff --git a/tests/test-lookup.R b/tests/test-lookup.R new file mode 100644 index 0000000..0223f4d --- /dev/null +++ b/tests/test-lookup.R @@ -0,0 +1,124 @@ +library(cepis) +source(file.path("helper", "utils.R")) + +with_server( + list(route("/01001000", response(200L, json(SE_ADDRESS)))), + function(client, server) { + address <- cep_lookup("01001-000", client) + check("lookup reads the address", identical(address$localidade, "S\u00e3o Paulo")) + check("lookup reads the state", identical(address$uf, "SP")) + check("lookup keeps the CEP unmasked", identical(address$cep, "01001000")) + check("formatted_cep masks the address", identical(formatted_cep(address), "01001-000")) + check("an address without geo has geo NULL", is.null(address$geo)) + check("the address is a cepis_address", inherits(address, "cepis_address")) + } +) + +precise <- c(SE_ADDRESS, list(geo = list( + lat = -23.5, lon = -46.6, raio_m = 120L, precisao = "logradouro" +))) +with_server( + list(route("/01310100", response(200L, json(precise)))), + function(client, server) { + address <- cep_lookup("01310100", client) + check("the coordinate is read", isTRUE(all.equal(address$geo$lat, -23.5))) + check("raio_m is an integer", identical(address$geo$raio_m, 120L)) + check("a street coordinate is precise", isTRUE(address$geo$is_precise)) + } +) + +municipality <- c(SE_ADDRESS, list(geo = list( + lat = -23.5, lon = -46.6, raio_m = 22000L, precisao = "localidade" +))) +with_server( + list(route("/01001000", response(200L, json(municipality)))), + function(client, server) { + address <- cep_lookup("01001000", client) + check( + "a municipality coordinate is not precise", + isFALSE(address$geo$is_precise) + ) + } +) + +broken <- c(SE_ADDRESS, list(geo = list(lat = "not a number"))) +with_server( + list(route("/01001000", response(200L, json(broken)))), + function(client, server) { + address <- cep_lookup("01001000", client) + check("a malformed geo is dropped", is.null(address$geo)) + check( + "a malformed geo does not lose the address", + identical(address$localidade, "S\u00e3o Paulo") + ) + } +) + +grown <- c(SE_ADDRESS, list(campo_do_futuro = "value")) +with_server( + list(route("/01001000", response(200L, json(grown)))), + function(client, server) { + address <- cep_lookup("01001000", client) + check( + "an unknown field is kept in raw", + identical(address$raw$campo_do_futuro, "value") + ) + } +) + +with_server( + list(route( + "/99999999", + response(404L, json(list(erro = "cep_nao_encontrado", mensagem = "gone"))) + )), + function(client, server) { + condition <- check_condition( + "a missing CEP signals cepis_not_found", + cep_lookup("99999999", client), + "cepis_not_found" + ) + check("the condition carries the CEP", identical(condition$cep, "99999999")) + check("404 is not retried, it is a definitive answer", calls(server, "/99999999") == 1L) + } +) + +with_server( + list(route("/01001000", response(200L, json(SE_ADDRESS)))), + function(client, server) { + check_condition( + "a malformed CEP never leaves the machine", + cep_lookup("123", client), + "cepis_invalid_cep" + ) + check("no request was made for it", calls(server) == 0L) + } +) + +with_server( + list(route( + "/00000000", + response(400L, json(list(erro = "cep_invalido", mensagem = "rejected by the API"))) + )), + function(client, server) { + condition <- check_condition( + "a 400 becomes cepis_invalid_cep", + cep_lookup("00000000", client), + "cepis_invalid_cep" + ) + check( + "it carries the message the API sent", + grepl("rejected by the API", conditionMessage(condition), fixed = TRUE) + ) + } +) + +with_server( + list(route("/01001000", response(200L, "<html>captive portal</html>"))), + function(client, server) { + check_condition( + "a body that is not JSON signals cepis_invalid_response", + cep_lookup("01001000", client), + "cepis_invalid_response" + ) + } +) diff --git a/tests/test-normalize.R b/tests/test-normalize.R new file mode 100644 index 0000000..cbfe67f --- /dev/null +++ b/tests/test-normalize.R @@ -0,0 +1,39 @@ +library(cepis) +source(file.path("helper", "utils.R")) + +for (value in c("01001000", "01001-000", " 01001-000 ", "01001.000")) { + check( + paste0("normalize_cep accepts ", value), + identical(normalize_cep(value), "01001000") + ) +} + +for (value in c("", "123", "0100100a", "010010000", "abcdefgh")) { + check_condition( + paste0("normalize_cep rejects ", value), + normalize_cep(value), + "cepis_invalid_cep" + ) +} + +check_condition( + "normalize_cep rejects a number, which has lost its leading zero", + normalize_cep(1001000), + "cepis_invalid_cep" +) + +check_condition( + "normalize_cep rejects a vector of length two", + normalize_cep(c("01001000", "20040020")), + "cepis_invalid_cep" +) + +condition <- caught(normalize_cep("123")) +check( + "the condition is catchable as cepis_error and as error", + inherits(condition, "cepis_error") && inherits(condition, "error") +) +check("the condition carries the offending value", identical(condition$value, "123")) + +check("formatted_cep adds the mask", identical(formatted_cep("01001000"), "01001-000")) +check("formatted_cep leaves a non-CEP alone", identical(formatted_cep("x"), "x")) diff --git a/tests/test-retry.R b/tests/test-retry.R new file mode 100644 index 0000000..bb93d4e --- /dev/null +++ b/tests/test-retry.R @@ -0,0 +1,121 @@ +library(cepis) +source(file.path("helper", "utils.R")) + +with_server( + list(route( + "/01001000", + response(500L, json(list(erro = "interno"))), + response(200L, json(SE_ADDRESS)) + )), + function(client, server) { + address <- cep_lookup("01001000", client) + check("a 500 is retried and the second attempt wins", identical(address$uf, "SP")) + check("it took exactly two attempts", calls(server, "/01001000") == 2L) + } +) + +with_server( + list(route("/01001000", response(503L, json(list(erro = "interno"))))), + function(client, server) { + condition <- check_condition( + "a 5xx on every attempt signals cepis_server_error", + cep_lookup("01001000", client), + "cepis_server_error" + ) + check("it exposes the status", identical(condition$status, 503L)) + check("three attempts is the documented default", calls(server, "/01001000") == 3L) + } +) + +with_server( + list(route("/01001000", response(500L, "{}"))), + function(client, server) { + single <- cepis_client(base_url = server$url, timeout = 5, max_attempts = 1L) + check_condition( + "max_attempts = 1 disables retrying", + cep_lookup("01001000", single), + "cepis_server_error" + ) + check("only one request was made", calls(server, "/01001000") == 1L) + } +) + +with_server( + list(route("/01001000", response(429L, "{}", list("Retry-After" = "7")))), + function(client, server) { + condition <- check_condition( + "a 429 signals cepis_rate_limited", + cep_lookup("01001000", client), + "cepis_rate_limited" + ) + check("it exposes retry_after in seconds", isTRUE(all.equal(condition$retry_after, 7))) + } +) + +with_server( + list(route("/01001000", response(429L, "{}", list("Retry-After" = "3600")))), + function(client, server) { + started <- proc.time()[["elapsed"]] + check_condition( + "an absurd Retry-After still signals cepis_rate_limited", + cep_lookup("01001000", client), + "cepis_rate_limited" + ) + check( + "backoff_max caps it, so a bad proxy cannot park the session", + proc.time()[["elapsed"]] - started < 5 + ) + } +) + +with_server( + list(route( + "/01001000", + response(429L, "{}", list("Retry-After" = "Wed, 21 Oct 2026 07:28:00 GMT")) + )), + function(client, server) { + condition <- check_condition( + "Retry-After as an HTTP date is understood", + cep_lookup("01001000", client), + "cepis_rate_limited" + ) + check( + "the date became a positive number of seconds", + is.numeric(condition$retry_after) && condition$retry_after > 0 + ) + } +) + +refused <- cepis_client( + base_url = "http://127.0.0.1:1", + timeout = 1, + max_attempts = 2L, + backoff_base = 0.001 +) +check_condition( + "a refused connection signals cepis_network_error", + cep_lookup("01001000", refused), + "cepis_network_error" +) + +with_server( + list(route("/01001000", response(500L, "{}"))), + function(client, server) { + bounded <- cepis_client( + base_url = server$url, + timeout = 5, + max_attempts = 50L, + backoff_base = 0.05, + backoff_max = 0.05, + total_timeout = 0.3 + ) + started <- proc.time()[["elapsed"]] + check_condition( + "total_timeout still ends in cepis_server_error", + cep_lookup("01001000", bounded), + "cepis_server_error" + ) + check("it bounded the whole call", proc.time()[["elapsed"]] - started < 5) + check("it cut the run short of the 50 attempts", calls(server, "/01001000") < 50L) + } +) |