# 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 "#" 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 "#" 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 "#" end end end