summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md39
-rw-r--r--cepis.asd4
-rw-r--r--src/client.lisp21
-rw-r--r--src/errors.lisp21
-rw-r--r--src/models.lisp54
-rw-r--r--src/package.lisp13
-rw-r--r--tests/suite.lisp68
7 files changed, 215 insertions, 5 deletions
diff --git a/README.md b/README.md
index 4baa530..9caf827 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/cepis.asd b/cepis.asd
index 7813f59..a6e076e 100644
--- a/cepis.asd
+++ b/cepis.asd
@@ -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))