summaryrefslogtreecommitdiffstats
path: root/README.md
diff options
context:
space:
mode:
Diffstat (limited to 'README.md')
-rw-r--r--README.md221
1 files changed, 221 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4baa530
--- /dev/null
+++ b/README.md
@@ -0,0 +1,221 @@
+# 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.
+
+`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).