diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:32:15 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:32:15 -0300 |
| commit | 36a0ddb01a4fcb8fb2fefd7c407ecca0e3e56e7c (patch) | |
| tree | bfd0a067edb5e802725bab48ebc7a0a09838feb1 /lib/cepis/models.rb | |
| download | ruby-36a0ddb01a4fcb8fb2fefd7c407ecca0e3e56e7c.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'lib/cepis/models.rb')
| -rw-r--r-- | lib/cepis/models.rb | 193 |
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 |