From b402de683eecef4c890081dd72edbcaf57f375e0 Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Mon, 27 Jul 2026 20:32:01 -0300 Subject: Expose the band of a CEP that does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CEP that fails a lookup is rarely a random number: it is someone who knows the city and guessed the digits, most often the -000 that 957 municipalities never received. The API now answers that 404 with an optional faixa naming the municipality those numbers belong to, so the caller can say "not found, but you seem to be looking at Areal/RJ" instead of just "not found". It stays an error. Faixa hangs off CEPNotFound and nothing about it promotes a nonexistent CEP into an address. Its cep_ini/cep_fim are the smallest and largest CEP that really exist there, not the published bounds — the DNE's own start for Areal is the CEP that just failed — and the band is sparse, which is what ceps is for; both facts are spelled out in the docs so nobody iterates the pair. Parsing is deliberately toothless: a missing, empty or malformed faixa, or a 404 body that is not JSON at all, all yield nil. Losing the consolation prize must never replace a clean not-found with a parse failure. --- lib/cepis/models.rb | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) (limited to 'lib/cepis/models.rb') diff --git a/lib/cepis/models.rb b/lib/cepis/models.rb index a0181ff..a80fe37 100644 --- a/lib/cepis/models.rb +++ b/lib/cepis/models.rb @@ -145,6 +145,91 @@ module Cepis 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 -- cgit v1.3