summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.Rbuildignore8
-rw-r--r--.gitignore6
-rw-r--r--DESCRIPTION25
-rw-r--r--LICENSE15
-rw-r--r--Makefile59
-rw-r--r--NAMESPACE12
-rw-r--r--R/api.R182
-rw-r--r--R/cepis-package.R24
-rw-r--r--R/client.R122
-rw-r--r--R/conditions.R73
-rw-r--r--R/http.R210
-rw-r--r--R/models.R212
-rw-r--r--R/normalize.R43
-rw-r--r--README.md207
-rw-r--r--man/cep_health.Rd34
-rw-r--r--man/cep_lookup.Rd46
-rw-r--r--man/cep_lookup_many.Rd51
-rw-r--r--man/cepis-package.Rd38
-rw-r--r--man/cepis_address.Rd34
-rw-r--r--man/cepis_client.Rd64
-rw-r--r--man/cepis_conditions.Rd42
-rw-r--r--man/cepis_geo.Rd36
-rw-r--r--man/cepis_health.Rd23
-rw-r--r--man/formatted_cep.Rd25
-rw-r--r--man/normalize_cep.Rd33
-rw-r--r--tests/helper/server.R114
-rw-r--r--tests/helper/utils.R127
-rw-r--r--tests/test-batch.R78
-rw-r--r--tests/test-config.R27
-rw-r--r--tests/test-health.R33
-rw-r--r--tests/test-lookup.R124
-rw-r--r--tests/test-normalize.R39
-rw-r--r--tests/test-retry.R121
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
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -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)
diff --git a/R/api.R b/R/api.R
new file mode 100644
index 0000000..0d13c67
--- /dev/null
+++ b/R/api.R
@@ -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)
+ }
+)