summaryrefslogtreecommitdiffstats
path: root/lib/cepis/models.rb
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:32:15 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:32:15 -0300
commit36a0ddb01a4fcb8fb2fefd7c407ecca0e3e56e7c (patch)
treebfd0a067edb5e802725bab48ebc7a0a09838feb1 /lib/cepis/models.rb
downloadruby-36a0ddb01a4fcb8fb2fefd7c407ecca0e3e56e7c.tar.gz
Initial importv0.1.0
Diffstat (limited to 'lib/cepis/models.rb')
-rw-r--r--lib/cepis/models.rb193
1 files changed, 193 insertions, 0 deletions
diff --git a/lib/cepis/models.rb b/lib/cepis/models.rb
new file mode 100644
index 0000000..a0181ff
--- /dev/null
+++ b/lib/cepis/models.rb
@@ -0,0 +1,193 @@
+# frozen_string_literal: true
+
+module Cepis
+ # 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 {#precisao} before using {#lat}/{#lon} for distance:
+ #
+ # - `"logradouro"`: the point is a stretch of street. {#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. {#raio_m} runs from 7 km to 40 km.
+ # Computing shipping from this is wrong by tens of kilometres.
+ #
+ # {#raio_m} is the radius containing 90% of that CEP's addresses. It exists
+ # precisely so that error cannot pass unnoticed.
+ class Geo
+ attr_reader :lat, :lon, :raio_m, :precisao
+
+ def initialize(lat:, lon:, raio_m:, precisao:)
+ @lat = lat
+ @lon = lon
+ @raio_m = raio_m
+ @precisao = precisao
+ freeze
+ end
+
+ # Build from the hash the API returned, or `nil` when the block is missing
+ # or malformed.
+ #
+ # A malformed `geo` is not worth losing the whole address over, since the
+ # rest of the response is still useful.
+ def self.from_json(data)
+ return nil unless data.is_a?(Hash)
+
+ block = data.transform_keys(&:to_s)
+ new(
+ lat: Float(block.fetch("lat")),
+ lon: Float(block.fetch("lon")),
+ raio_m: Float(block.fetch("raio_m")).to_i,
+ precisao: String(block.fetch("precisao"))
+ )
+ rescue KeyError, TypeError, ArgumentError
+ nil
+ end
+
+ # `true` when the coordinate points at a street, not a municipality.
+ #
+ # Shorthand for `precisao == "logradouro"`, which is the check to make
+ # before using the point to measure distance.
+ def precise?
+ @precisao == "logradouro"
+ end
+
+ def to_h
+ { lat: @lat, lon: @lon, raio_m: @raio_m, precisao: @precisao }
+ end
+
+ def ==(other)
+ other.is_a?(Geo) && to_h == other.to_h
+ end
+ alias eql? ==
+
+ def hash
+ to_h.hash
+ end
+
+ def to_s
+ "#<Cepis::Geo #{@lat},#{@lon} ±#{@raio_m}m #{@precisao}>"
+ end
+ end
+
+ # A CEP and everything the database knows about it.
+ #
+ # **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`,
+ # `localidade`. Everything else in this library is English; the fields are
+ # the one deliberate exception, so that what you read in the JSON at
+ # https://cep.is/01001000 is exactly what you read on the object.
+ #
+ # Fields the database does not have for that CEP come back as empty strings,
+ # never `nil` — that is what the API returns.
+ #
+ # {#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.
+ #
+ # {#raw} holds the response exactly as it arrived, uninterpreted, so a field
+ # the API grows before this library is updated is not lost.
+ class Address
+ FIELDS = %i[
+ cep logradouro complemento unidade bairro localidade
+ uf estado regiao ibge gia ddd siafi
+ ].freeze
+
+ attr_reader(*FIELDS)
+ attr_reader :geo, :raw
+
+ def initialize(geo: nil, raw: {}, **fields)
+ FIELDS.each { |name| instance_variable_set(:"@#{name}", fields[name] || "") }
+ @geo = geo
+ @raw = raw.freeze
+ freeze
+ end
+
+ # Build from the hash the API returned.
+ #
+ # Unknown fields are ignored during construction but preserved in {#raw}. A
+ # new field in the API therefore cannot break this library.
+ def self.from_json(data)
+ values = data.transform_keys(&:to_s)
+ fields = FIELDS.each_with_object({}) do |name, acc|
+ acc[name] = String(values[name.to_s] || "")
+ end
+ new(geo: Geo.from_json(values["geo"]), raw: values, **fields)
+ end
+
+ # The CEP with its mask: `01001-000`.
+ #
+ # The API always returns it unmasked; formatting is presentation, and it
+ # lives here so it does not become a repeated interpolation in every
+ # project that uses this.
+ def formatted_cep
+ "#{@cep[0, 5]}-#{@cep[5, 3]}"
+ end
+
+ def to_h
+ FIELDS.each_with_object({}) { |name, acc| acc[name] = public_send(name) }
+ .merge(geo: @geo&.to_h)
+ end
+
+ def ==(other)
+ other.is_a?(Address) && to_h == other.to_h
+ end
+ alias eql? ==
+
+ def hash
+ to_h.hash
+ end
+
+ def to_s
+ "#<Cepis::Address #{formatted_cep} #{@logradouro} #{@localidade}/#{@uf}>"
+ end
+ end
+
+ # 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; {#dne} identifies it.
+ class Health
+ attr_reader :status, :dne, :ceps, :geo, :base_gerada_em
+
+ def initialize(status:, dne:, ceps:, geo:, base_gerada_em:)
+ @status = status
+ @dne = dne
+ @ceps = ceps
+ @geo = geo
+ @base_gerada_em = base_gerada_em
+ freeze
+ end
+
+ # Build from the hash the API returned.
+ def self.from_json(data)
+ values = data.transform_keys(&:to_s)
+ new(
+ status: String(values["status"] || ""),
+ dne: String(values["dne"] || ""),
+ ceps: values["ceps"].to_i,
+ geo: values["geo"].to_i,
+ base_gerada_em: String(values["base_gerada_em"] || "")
+ )
+ end
+
+ # `true` when the API reports itself healthy.
+ def ok?
+ @status == "ok"
+ end
+
+ def to_h
+ {
+ status: @status, dne: @dne, ceps: @ceps, geo: @geo,
+ base_gerada_em: @base_gerada_em
+ }
+ end
+
+ def to_s
+ "#<Cepis::Health #{@status} dne=#{@dne} ceps=#{@ceps}>"
+ end
+ end
+end