(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")))