# cepis Common Lisp client for the [public CEP API at cep.is](https://cep.is). Official Correios data (DNE), refreshed every two weeks. Developed and tested on SBCL. ```lisp (let ((address (cepis:lookup "01001-000"))) (format t "~a ~a ~a~%" (cepis:address-logradouro address) (cepis:address-localidade address) (cepis:address-uf address))) ;; Praça da Sé São Paulo SP ``` ## Installation Clone it where ASDF can find it, for instance `~/common-lisp/`, then load it with Quicklisp so the dependencies come along: ```sh git clone https://git.cep.is/lisp.git ~/common-lisp/cepis ``` ```lisp (ql:quickload "cepis") ``` Three dependencies: [`dexador`](https://github.com/fukamachi/dexador) for HTTP, because the standard has no client and dexador is the maintained, synchronous, TLS-capable one; [`com.inuoe.jzon`](https://github.com/Zulu-Inuoe/jzon) for JSON, because it is the strictest of the current parsers and does not invent its own reader syntax; and [`babel`](https://github.com/cl-babel/babel) to decode UTF-8 bodies. The batch endpoint's CSV is read by a small RFC 4180 reader included here — less total weight than pulling a CSV library in for one call. ## Usage ### A single lookup `lookup` accepts the CEP masked or unmasked and returns an `address`: ```lisp (defvar *a* (cepis:lookup "01310-100")) (cepis:address-logradouro *a*) ; "Avenida Paulista" (cepis:address-localidade *a*) ; "São Paulo" (cepis:address-uf *a*) ; "SP" (cepis:address-ibge *a*) ; "3550308" (cepis:address-cep *a*) ; "01310100" (always unmasked) (cepis:address-formatted-cep *a*) ; "01310-100" ``` Fields the database does not have for that CEP come back as empty strings, never `nil`. The slot names are the API's own, in Portuguese, with Lisp's hyphenation where the JSON has an underscore: `raio_m` is read with `geo-raio-m`. ### Coordinates About 57% of CEPs have a coordinate. When they do not, `address-geo` is `nil`: ```lisp (let ((geo (cepis:address-geo *a*))) (when geo (list (cepis:geo-lat geo) ; -23.567202d0 (cepis:geo-lon geo) ; -46.649659d0 (cepis:geo-raio-m geo) ; 1084 (cepis:geo-precisao geo)))) ; "logradouro" ``` **Check `geo-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 | `geo-precise-p` is the shorthand for `precisao` being `"logradouro"`: ```lisp (let ((geo (cepis:address-geo *a*))) (if (and geo (cepis:geo-precise-p geo)) (shipping-from (cepis:geo-lat geo) (cepis:geo-lon geo)) (shipping-from-municipality (cepis:address-ibge *a*)))) ``` ### Many CEPs at once Do not loop over `lookup`. Use `lookup-many`, which resolves up to 500 CEPs per request: ```lisp (let ((ceps '("01001000" "99999999" "20040020"))) (loop for cep in ceps for address in (cepis:lookup-many ceps) do (format t "~a: ~a~%" cep (if address (cepis:address-localidade address) "not found")))) ``` The returned list has **the same length and the same order** as the input. A CEP that does not exist, or is malformed, is `nil` at its position — it does not vanish, and it does not signal. Lists 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 `lookup-many` has > `address-geo` of `nil`, including for CEPs that do have one. Need the > coordinate? Use `lookup`, one at a time. ### API health Useful for two things: checking the API answers, and knowing **which version of the database** is live: ```lisp (let ((h (cepis:health))) (cepis:health-ok-p h) ; T (cepis:health-dne h) ; "26071" identifier of the fortnightly database (cepis:health-ceps h) ; 1596168 (cepis:health-base-gerada-em h)); "2026-07-24T03:48:28Z" ``` An API reporting itself degraded does not signal — that arrives in `health-status`. ## Configuration For anything beyond the defaults, build a `client` and reuse it. It keeps no state between calls and is safe to share across threads: ```lisp (defvar *cep* (cepis:make-client :base-url "https://cep.is" ; point at another instance :timeout 10.0 ; seconds per socket operation :max-attempts 3 ; counting the first; 1 disables retrying :backoff-base 0.5 ; base of the exponential wait :backoff-max 8.0 ; cap on each wait :total-timeout nil)) ; cap on the whole call, attempts included (cepis:lookup "01001000" :client *cep*) ``` Retries cover 429, 5xx and network failures, with exponential backoff and jitter; 400 and 404 are never retried. `timeout` bounds each socket operation, not the whole call — that is what `total-timeout` is for. See the docstring of `make-client` for the details of each parameter. `lookup`, `lookup-many` and `health` use the shared client in `cepis:*client*` when no `:client` is given. Do not mutate it: it applies process-wide, including to third-party libraries that also use this package. Need a different configuration? Build your own. ### 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`: ```lisp (cepis:make-client :base-url "http://localhost:8080") ``` ## Errors Everything derives from `cepis-error`, so one handler catches any failure. Below that, the split is by **the decision you have to make**, not by the HTTP status that caused it: | condition | when | does retrying help? | | --- | --- | --- | | `invalid-cep` | not 8 digits | no | | `cep-not-found` | well-formed, not in the database | no | | `rate-limited` | 429 | yes, later | | `server-error` | 5xx | yes, later | | `network-error` | DNS, connection, timeout, TLS | yes, later | | `invalid-response` | unintelligible response | probably not | ```lisp (handler-case (cepis:lookup cep-from-form) (cepis:cep-not-found () :ask-for-another-cep) (cepis:cepis-error () :reschedule)) ``` `error-message` returns the description of any of them; `server-error-status` and `rate-limited-retry-after` carry the extra facts their callers need. ### The municipality behind a CEP that does not exist A CEP that fails is rarely random. It is usually someone who knows the city and guessed the number — and the classic guess is the `-000` that 957 municipalities never received. So `cep-not-found` carries a `faixa`: the municipality that owns the band the CEP falls in, when the API knows one. ```lisp (handler-case (cepis:lookup "25845000") (cepis:cep-not-found (e) (let ((faixa (cepis:cep-not-found-faixa e))) (if faixa (format nil "This CEP does not exist. Did you mean somewhere in ~a/~a?" (cepis:faixa-localidade faixa) ; "Areal" (cepis:faixa-uf faixa)) ; "RJ" "This CEP does not exist.")))) ``` The rest of the fields are `faixa-estado`, `faixa-regiao`, `faixa-ibge`, `faixa-cep-ini`, `faixa-cep-fim` and `faixa-ceps`. Three things about it, all of which are easy to get wrong: - **It is still a 404.** The condition is signalled exactly as before; the `faixa` is context for the error, not an address. A municipality here never means the CEP resolved. - **`cep-ini` and `cep-fim` are the CEPs that actually exist**, the smallest and the largest in the DNE for that municipality — not the bounds the Correios publish. Areal's official start is `25845000`, which is precisely the CEP that just failed; `faixa-cep-ini` is `25845001`. - **The band is sparse.** Areal's 118 CEPs are scattered over the ~4,900 numbers between its two ends, and `faixa-ceps` is there to say so. Never present the pair as a range of valid CEPs. `cep-not-found-faixa` is `nil` when the CEP falls in a band no municipality owns — the API omits the field, it never sends null — and also when the body cannot be read. A malformed `faixa` never turns a clean not-found into an `invalid-response`. `invalid-cep` is also a `parse-error` and `cep-not-found` a `cell-error`, so code already handling those standard types during input validation keeps working without knowing this library exists — and `cell-error-name` gives back the CEP that was not found. `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`, which drives SBCL in script mode. Quicklisp must be installed and loaded from your `~/.sbclrc`: ```sh make # list the targets make test # run the suite make lint # recompile from scratch, failing on any warning make build # compile and load the library make check # lint, then test, then build ``` The suite stands up a real HTTP server on a loopback port and points a client at it with `:base-url`. Nothing is stubbed and no test touches the network. ## Licence ISC. See [LICENSE](LICENSE).