# 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 municipality that owns the band a **nonexistent** CEP falls into. # # It is context for an error, never an address: it arrives on the 404 of # {Client#lookup}, hanging off {CEPNotFound#faixa}, and says "this CEP does # not exist, but the numbers around it belong to Areal/RJ". The lookup still # failed — nothing here turns it into a result. # # It is worth having because a CEP that fails is rarely a random number: it # is usually someone who knows the city and guessed the digits. The classic # guess is the `-000`, which 957 municipalities never received. # # **{#cep_ini} and {#cep_fim} are the smallest and the largest CEP that # actually exist in the municipality, not the bounds the Correios publish.** # The DNE's own start for Areal is `25845000` — exactly the CEP that just # failed, which is why the published bounds would be useless here. # # **The band is sparse.** Areal's 118 CEPs are scattered across the ~4,900 # numbers between `25845001` and `25849899`. {#ceps} is there to say so. Do # not present the pair as a range of valid CEPs, and do not iterate it: all # but a fiftieth of it does not exist. class Faixa attr_reader :localidade, :uf, :estado, :regiao, :ibge, :cep_ini, :cep_fim, :ceps def initialize(localidade:, uf:, estado:, regiao:, ibge:, cep_ini:, cep_fim:, ceps:) @localidade = localidade @uf = uf @estado = estado @regiao = regiao @ibge = ibge @cep_ini = cep_ini @cep_fim = cep_fim @ceps = ceps freeze end # Build from the hash the API returned, or `nil` when the block is # missing, empty or malformed. # # The API **omits** `faixa` — it never sends null — for a CEP falling in a # band no municipality owns, so absence is the ordinary case and not an # anomaly. Anything unreadable is treated as absence too: this is a bonus # on top of a failure that has already happened, and it must never be the # reason a clean not-found becomes something else. def self.from_json(data) return nil unless data.is_a?(Hash) block = data.transform_keys(&:to_s) localidade = String(block["localidade"] || "").strip return nil if localidade.empty? new( localidade: localidade, uf: String(block["uf"] || ""), estado: String(block["estado"] || ""), regiao: String(block["regiao"] || ""), ibge: String(block["ibge"] || ""), cep_ini: String(block["cep_ini"] || ""), cep_fim: String(block["cep_fim"] || ""), ceps: Integer(block["ceps"] || 0, exception: false) || 0 ) rescue TypeError, ArgumentError nil end def to_h { localidade: @localidade, uf: @uf, estado: @estado, regiao: @regiao, ibge: @ibge, cep_ini: @cep_ini, cep_fim: @cep_fim, ceps: @ceps } end def ==(other) other.is_a?(Faixa) && 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