diff options
Diffstat (limited to 'src/models.lisp')
| -rw-r--r-- | src/models.lisp | 147 |
1 files changed, 147 insertions, 0 deletions
diff --git a/src/models.lisp b/src/models.lisp new file mode 100644 index 0000000..8905ae1 --- /dev/null +++ b/src/models.lisp @@ -0,0 +1,147 @@ +(in-package #:cepis) + +(defstruct (geo (:copier nil)) + "The CEP's coordinate, when the database has one. + +The point is the median of the coordinates of that CEP's addresses, not the +mean: one badly geocoded address 40 km away shifts a mean and does not move a +median. + +Always check `geo-precisao' before using the point for distance: + +- \"logradouro\": the point is a stretch of street. `geo-raio-m' lands around + 100-150 m. Good for maps and for distance. +- \"localidade\": the CEP is the whole municipality (a city-wide general CEP), + and the point is its centroid. `geo-raio-m' runs from 7 km to 40 km. + Computing shipping from this is wrong by tens of kilometres. + +`geo-raio-m' is the radius containing 90% of that CEP's addresses. It exists +precisely so that error cannot pass unnoticed. The slot is the JSON's `raio_m' +with Lisp's hyphenation." + (lat 0.0d0 :type double-float :read-only t) + (lon 0.0d0 :type double-float :read-only t) + (raio-m 0 :type integer :read-only t) + (precisao "" :type string :read-only t)) + +(defun geo-precise-p (geo) + "True when the coordinate points at a street, not at a municipality. + +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 (address (:copier nil)) + "A CEP and everything the database knows about it. + +Fields the database does not have for that CEP come back as empty strings, +never NIL — that is what the API returns, and changing it here would create a +difference between reading the JSON and reading this library. + +`address-geo' is the exception: it is NIL when there is no coordinate. There +the difference is real, because the API omits the whole field in that case +(about 43% of CEPs) rather than sending null. + +`address-raw' holds the response exactly as it arrived, as the hash table the +JSON parser produced, so a field the API grows before this library is updated +is not lost." + (cep "" :type string :read-only t) + (logradouro "" :type string :read-only t) + (complemento "" :type string :read-only t) + (unidade "" :type string :read-only t) + (bairro "" :type string :read-only t) + (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) + (gia "" :type string :read-only t) + (ddd "" :type string :read-only t) + (siafi "" :type string :read-only t) + (geo nil :type (or null geo) :read-only t) + (raw nil :type (or null hash-table) :read-only t)) + +(defun address-formatted-cep (address) + "The CEP with its mask: \"01001-000\". + +The native contract always returns it unmasked; formatting is presentation, +and it lives here so it does not become a repeated format string in every +project that uses this." + (let ((cep (address-cep address))) + (if (= (length cep) 8) + (format nil "~a-~a" (subseq cep 0 5) (subseq cep 5)) + cep))) + +(defstruct (health (:copier nil)) + "The response of GET /health. + +Good for two things: checking that the API answers, and knowing which version +of the database is live. The DNE database changes every two weeks; +`health-dne' identifies it." + (status "" :type string :read-only t) + (dne "" :type string :read-only t) + (ceps 0 :type integer :read-only t) + (geo 0 :type integer :read-only t) + (base-gerada-em "" :type string :read-only t)) + +(defun health-ok-p (health) + "True when the API reports itself healthy." + (string= (health-status health) "ok")) + +(defun json-string (table key) + "The value of KEY in TABLE as a string, or \"\" when it is absent or not a scalar." + (let ((value (gethash key table))) + (cond ((stringp value) value) + ((integerp value) (format nil "~d" value)) + ((realp value) (princ-to-string value)) + (t "")))) + +(defun json-integer (table key) + "The value of KEY in TABLE as an integer, or 0 when it is absent or not a number." + (let ((value (gethash key table))) + (if (realp value) (round value) 0))) + +(defun geo-from-json (value) + "Build a `geo' from the parsed \"geo\" block, or NIL when it is absent or malformed. + +A malformed block yields NIL rather than propagating the error: it is not +worth losing the whole address over, since the rest of the response is still +useful." + (when (hash-table-p value) + (let ((lat (gethash "lat" value)) + (lon (gethash "lon" value)) + (raio-m (gethash "raio_m" value)) + (precisao (gethash "precisao" value))) + (when (and (realp lat) (realp lon) (realp raio-m) (stringp precisao)) + (make-geo :lat (coerce lat 'double-float) + :lon (coerce lon 'double-float) + :raio-m (round raio-m) + :precisao precisao))))) + +(defun address-from-json (data) + "Build an `address' from the hash table the JSON parser produced. + +Unknown fields are ignored during construction but preserved in +`address-raw'. A new field in the API therefore cannot break this library." + (make-address :cep (json-string data "cep") + :logradouro (json-string data "logradouro") + :complemento (json-string data "complemento") + :unidade (json-string data "unidade") + :bairro (json-string data "bairro") + :localidade (json-string data "localidade") + :uf (json-string data "uf") + :estado (json-string data "estado") + :regiao (json-string data "regiao") + :ibge (json-string data "ibge") + :gia (json-string data "gia") + :ddd (json-string data "ddd") + :siafi (json-string data "siafi") + :geo (geo-from-json (gethash "geo" data)) + :raw data)) + +(defun health-from-json (data) + "Build a `health' from the hash table the JSON parser produced." + (make-health :status (json-string data "status") + :dne (json-string data "dne") + :ceps (json-integer data "ceps") + :geo (json-integer data "geo") + :base-gerada-em (json-string data "base_gerada_em"))) |