diff options
| -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)) |