diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:35:12 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:35:12 -0300 |
| commit | 2ed1190f0f021bc4de17b47d8d4ed7241611d6f1 (patch) | |
| tree | 221a8ba542c35ec66b4c79805067749e78c5e03f | |
| parent | ae9ad8a4c61b793f6859c84dac80b1c1b04b7b68 (diff) | |
| download | lisp-main.tar.gz | |
A CEP that fails is rarely random: it is someone who knows the city and
guessed the number, most often the -000 that 957 municipalities never
received. The API now answers that 404 with the municipality owning the
band the CEP falls in, and without it every caller can say no more than
"does not exist" to a person who is one digit away from the right CEP.
It lands on the condition rather than in a return value because it is
context for the failure and nothing else: the answer is still a 404, and
a faixa must never read as an address that resolved. Parsing it is
deliberately defensive for the same reason — a missing, malformed or
empty block yields NIL, so a field that only adds context cannot turn a
clean not-found into a parse failure, and an older API stays supported.
The ends of the band are the CEPs that actually exist, not the bounds
the Correios publish, and the band is sparse; the docstrings and the
README say so, because presenting the pair as a range of valid CEPs is
the mistake this data invites.
models is compiled before errors so the report function can name the
faixa's municipality.
| -rw-r--r-- | README.md | 39 | ||||
| -rw-r--r-- | cepis.asd | 4 | ||||
| -rw-r--r-- | src/client.lisp | 21 | ||||
| -rw-r--r-- | src/errors.lisp | 21 | ||||
| -rw-r--r-- | src/models.lisp | 54 | ||||
| -rw-r--r-- | src/package.lisp | 13 | ||||
| -rw-r--r-- | tests/suite.lisp | 68 |
7 files changed, 215 insertions, 5 deletions
@@ -191,6 +191,45 @@ status that caused it: `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 @@ -1,5 +1,5 @@ (defsystem "cepis" - :version "0.1.0" + :version "0.2.0" :author "Murilo Ijanc <[email protected]>" :license "ISC" :description "Client for the public CEP API at cep.is." @@ -8,8 +8,8 @@ :serial t :components ((:module "src" :components ((:file "package") - (:file "errors") (:file "models") + (:file "errors") (:file "csv") (:file "client")))) :in-order-to ((test-op (test-op "cepis/tests")))) diff --git a/src/client.lisp b/src/client.lisp index 2965c86..9dd5338 100644 --- a/src/client.lisp +++ b/src/client.lisp @@ -1,6 +1,6 @@ (in-package #:cepis) -(defparameter +version+ "0.1.0" +(defparameter +version+ "0.2.0" "Version of this library, as it appears in the default User-Agent.") (defparameter +default-url+ "https://cep.is" @@ -306,6 +306,19 @@ between you and the API is answering instead of it: ~s" fallback)) (error () fallback))) +(defun error-faixa (body) + "The \"faixa\" block of an error body as a `faixa', or NIL when there is none. + +Every failure to read it is NIL: a body that is not JSON, a `faixa' that is not +an object, an API too old to send the field. A not-found is a definitive answer +about the request, and a field that only says which municipality the CEP would +have belonged to must never be able to turn it into an `invalid-response'." + (handler-case + (let ((data (com.inuoe.jzon:parse (body-text body)))) + (when (hash-table-p data) + (faixa-from-json (gethash "faixa" data)))) + (error () nil))) + (defun lookup (cep &key (client *client*)) "Look up a single CEP and return an `address'. @@ -315,6 +328,11 @@ A 400 from the API is surfaced as `invalid-cep' carrying the API's own message: the format already passed local validation, so the server knows something this client does not. +A `cep-not-found' may carry a `faixa' in `cep-not-found-faixa': the +municipality that owns the band the CEP falls in, when the API knows one. It is +context for the error and nothing more — the answer is still a 404, and the +band is sparse, so it says which city to ask about, never which CEPs are valid. + Signals `invalid-cep' when the CEP is not 8 digits, without making a request; `cep-not-found' when it is well-formed but not in the database; `rate-limited' on 429 and `server-error' on 5xx after every attempt; `network-error' when the @@ -325,6 +343,7 @@ request never completed; `invalid-response' when the body cannot be read." (case status (404 (error 'cep-not-found :name number + :faixa (error-faixa body) :message (format nil "CEP ~a does not exist in the DNE database" number))) (400 (error 'invalid-cep diff --git a/src/errors.lisp b/src/errors.lisp index 525ac67..ea6f83f 100644 --- a/src/errors.lisp +++ b/src/errors.lisp @@ -30,12 +30,29 @@ Usually signalled without a request ever being made — the client checks the format before going to the network.")) (define-condition cep-not-found (cepis-error cell-error) - () + ((faixa :initarg :faixa + :initform nil + :reader cep-not-found-faixa + :documentation + "The municipality owning the band this CEP falls in, or NIL.")) + (:report (lambda (condition stream) + (write-string (error-message condition) stream) + (let ((faixa (cep-not-found-faixa condition))) + (when faixa + (format stream " — it falls in the band of ~a/~a" + (faixa-localidade faixa) + (faixa-uf faixa)))))) (:documentation "The CEP is well-formed but does not exist in the DNE database. Also a `cell-error', which is the closest thing the standard has to a failed -key lookup: `cell-error-name' returns the CEP that was not found.")) +key lookup: `cell-error-name' returns the CEP that was not found. + +`cep-not-found-faixa' is a `faixa' naming the municipality that owns the band +the CEP falls in, when the API knows one, and NIL otherwise — either because +no municipality owns that band or because an older API is answering. It is +context for the error, not an address: this is still a 404, and a caller must +not read a municipality here as a CEP that resolved.")) (define-condition network-error (cepis-error) () diff --git a/src/models.lisp b/src/models.lisp index 8905ae1..9ca0eaf 100644 --- a/src/models.lisp +++ b/src/models.lisp @@ -30,6 +30,36 @@ Shorthand for (string= (geo-precisao geo) \"logradouro\"), which is the check to make before using the point to measure distance." (string= (geo-precisao geo) "logradouro")) +(defstruct (faixa (:copier nil)) + "The municipality a CEP that does not exist would have belonged to. + +Context for an error, never an address. It arrives on the 404 of a CEP the DNE +does not have, and names the municipality that owns the band the CEP falls in. +A CEP that fails is rarely random: it is usually someone who knows the city and +guessed the number — the classic guess being the `-000' that 957 municipalities +never received. + +`faixa-cep-ini' and `faixa-cep-fim' are the smallest and the largest CEP that +*actually exist* in that municipality, not the bounds the Correios publish. For +Areal/RJ the DNE's own start is 25845000 — exactly the CEP that just failed — +while `faixa-cep-ini' is 25845001. + +They are not the ends of a list, either: the range is sparse. Areal's 118 CEPs +are scattered over the ~4,900 numbers between its two ends, which is what +`faixa-ceps' counts. Offer the municipality to whoever typed the CEP; never +present the pair as a range of valid CEPs. + +Both ends are strings, like every CEP in this library, because a leading zero +is part of the number." + (localidade "" :type string :read-only t) + (uf "" :type string :read-only t) + (estado "" :type string :read-only t) + (regiao "" :type string :read-only t) + (ibge "" :type string :read-only t) + (cep-ini "" :type string :read-only t) + (cep-fim "" :type string :read-only t) + (ceps 0 :type integer :read-only t)) + (defstruct (address (:copier nil)) "A CEP and everything the database knows about it. @@ -117,6 +147,30 @@ useful." :raio-m (round raio-m) :precisao precisao))))) +(defun faixa-from-json (value) + "Build a `faixa' from the parsed \"faixa\" block, or NIL when it is absent or +malformed. + +The API omits the field entirely — it never sends null — when the CEP falls in +a band no municipality owns, so an absent block is the ordinary case and not an +anomaly. + +Anything that is not a block naming a municipality also yields NIL. That is the +whole point of parsing it here and not at the call site: the response is a +definitive not-found, and a field that only adds context must never be able to +turn it into a parse failure." + (when (hash-table-p value) + (let ((localidade (json-string value "localidade"))) + (when (plusp (length localidade)) + (make-faixa :localidade localidade + :uf (json-string value "uf") + :estado (json-string value "estado") + :regiao (json-string value "regiao") + :ibge (json-string value "ibge") + :cep-ini (json-string value "cep_ini") + :cep-fim (json-string value "cep_fim") + :ceps (json-integer value "ceps")))))) + (defun address-from-json (data) "Build an `address' from the hash table the JSON parser produced. diff --git a/src/package.lisp b/src/package.lisp index 20d6f32..274c654 100644 --- a/src/package.lisp +++ b/src/package.lisp @@ -44,6 +44,19 @@ Lisp's hyphenation, so the JSON `raio_m' is the slot `raio-m', read with #:error-message #:server-error-status #:rate-limited-retry-after + #:cep-not-found-faixa + + #:faixa + #:faixa-p + #:make-faixa + #:faixa-localidade + #:faixa-uf + #:faixa-estado + #:faixa-regiao + #:faixa-ibge + #:faixa-cep-ini + #:faixa-cep-fim + #:faixa-ceps #:geo #:geo-p diff --git a/tests/suite.lisp b/tests/suite.lisp index 4ef0c5f..af48426 100644 --- a/tests/suite.lisp +++ b/tests/suite.lisp @@ -11,10 +11,20 @@ (defparameter +csv-header+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi") +(defparameter +areal-faixa+ + "\"faixa\":{\"localidade\":\"Areal\",\"uf\":\"RJ\",\"estado\":\"Rio de Janeiro\",\"regiao\":\"Sudeste\",\"ibge\":\"3300225\",\"cep_ini\":\"25845001\",\"cep_fim\":\"25849899\",\"ceps\":118}" + "The band 25845000 falls in: the CEP that fails is the DNE's own start for +Areal, which is why the band begins one number later.") + (defun se-json (&rest extra-fields) "The fixture address, with EXTRA-FIELDS spliced in as raw JSON members." (format nil "{~a~{,~a~}}" +se-fields+ extra-fields)) +(defun not-found-json (&rest extra-fields) + "The 404 body, with EXTRA-FIELDS spliced in as raw JSON members." + (format nil "{\"erro\":\"cep_nao_encontrado\",\"mensagem\":\"CEP não existe na base do DNE\"~{,~a~}}" + extra-fields)) + (defun csv-body (&rest rows) (format nil "~a~%~{~a~%~}" +csv-header+ rows)) @@ -90,6 +100,64 @@ (signals cep-not-found (lookup "99999999" :client client)) (is (= 1 (call-count server "/99999999"))))) +(test not-found-carries-the-band-it-falls-in + (with-fake-server (server client) + (respond server "/25845000" (reply 404 (not-found-json +areal-faixa+))) + (handler-case (progn (lookup "25845000" :client client) (fail "expected a cep-not-found")) + (cep-not-found (condition) + (let ((faixa (cep-not-found-faixa condition))) + (is (string= "25845000" (cell-error-name condition))) + (is (string= "Areal" (faixa-localidade faixa))) + (is (string= "RJ" (faixa-uf faixa))) + (is (string= "Rio de Janeiro" (faixa-estado faixa))) + (is (string= "Sudeste" (faixa-regiao faixa))) + (is (string= "3300225" (faixa-ibge faixa))) + ;; The ends are the CEPs that exist, not the bounds the Correios + ;; publish: the DNE's own start for Areal is the 25845000 that just + ;; failed. And the 118 of them are scattered over the ~4,900 numbers + ;; in between, so the pair is not a list of valid CEPs. + (is (string= "25845001" (faixa-cep-ini faixa))) + (is (string= "25849899" (faixa-cep-fim faixa))) + (is (= 118 (faixa-ceps faixa)))))))) + +(test the-band-does-not-make-a-not-found-succeed + (with-fake-server (server client) + (respond server "/25845000" (reply 404 (not-found-json +areal-faixa+))) + (signals cep-not-found (lookup "25845000" :client client)) + (is (= 1 (call-count server "/25845000"))))) + +(test not-found-without-a-band + (with-fake-server (server client) + (respond server "/99999999" (reply 404 (not-found-json))) + (handler-case (progn (lookup "99999999" :client client) (fail "expected a cep-not-found")) + (cep-not-found (condition) + (is (null (cep-not-found-faixa condition))))))) + +(test malformed-band-does-not-lose-the-not-found + (dolist (field '("\"faixa\":\"Areal\"" "\"faixa\":{}" "\"faixa\":[]" + "\"faixa\":null" "\"faixa\":{\"uf\":\"RJ\"}")) + (with-fake-server (server client) + (respond server "/25845000" (reply 404 (not-found-json field))) + (handler-case (progn (lookup "25845000" :client client) (fail "expected a cep-not-found")) + (cep-not-found (condition) + (is (null (cep-not-found-faixa condition)))))))) + +(test not-found-body-that-is-not-json-still-signals-not-found + (with-fake-server (server client) + (respond server "/25845000" (reply 404 "<html>captive portal</html>")) + (signals cep-not-found (lookup "25845000" :client client)))) + +(test the-report-names-the-municipality + (with-fake-server (server client) + (respond server "/25845000" (reply 404 (not-found-json +areal-faixa+))) + (respond server "/99999999" (reply 404 (not-found-json))) + (handler-case (progn (lookup "25845000" :client client) (fail "expected a cep-not-found")) + (cep-not-found (condition) + (is (search "Areal" (princ-to-string condition))))) + (handler-case (progn (lookup "99999999" :client client) (fail "expected a cep-not-found")) + (cep-not-found (condition) + (is (search "99999999" (princ-to-string condition))))))) + (test invalid-cep-never-leaves-the-machine (with-fake-server (server client) (signals invalid-cep (lookup "123" :client client)) |