diff options
| -rw-r--r-- | README.md | 44 | ||||
| -rw-r--r-- | lib/cepis.rb | 7 | ||||
| -rw-r--r-- | lib/cepis/client.rb | 41 | ||||
| -rw-r--r-- | lib/cepis/errors.rb | 20 | ||||
| -rw-r--r-- | lib/cepis/models.rb | 85 | ||||
| -rw-r--r-- | lib/cepis/version.rb | 2 | ||||
| -rw-r--r-- | test/cepis_test.rb | 61 | ||||
| -rw-r--r-- | test/test_helper.rb | 16 |
8 files changed, 270 insertions, 6 deletions
@@ -180,6 +180,50 @@ end `RateLimited#retry_after` carries what the server asked for, in seconds, and `ServerError#status` the HTTP status of the last attempt. +### The band of a CEP that does not exist + +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. + +So the 404 tries to say something useful. When the numbers around the one you +asked for belong to a municipality, `CEPNotFound#faixa` names it: + +```ruby +begin + Cepis.lookup("25845-000") +rescue Cepis::CEPNotFound => e + if e.faixa + puts e.faixa.localidade # "Areal" + puts e.faixa.uf # "RJ" + puts e.faixa.ibge # "3300225" + puts e.faixa.ceps # 118 + puts e.faixa.cep_ini # "25845001" + puts e.faixa.cep_fim # "25849899" + end +end +``` + +Three things about it, all of which are easy to get wrong: + +- **It is still a 404.** `faixa` is context for the failure, not a fallback + address. The CEP asked for does not exist; do not save it, do not ship to + it. It is for the error message, the log line and the "did you mean + somewhere in Areal?" prompt. +- **`cep_ini` and `cep_fim` are the smallest and the largest CEP that actually + exist there**, 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`. That is what `ceps` is for. The + pair is not a range of valid CEPs, and iterating it produces about fifty + nonexistent numbers for each real one. + +`faixa` is `nil` when the CEP falls in a band no municipality owns — the API +**omits** the field there, it never sends null — and also when the 404 body is +unreadable. Reading it can never fail loudly: a not-found stays a clean +`CEPNotFound`, never an `InvalidResponse`. + `InvalidResponse` almost always means something between you and the API is answering instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error page. Useful as a diagnosis: the problem is not in your code. diff --git a/lib/cepis.rb b/lib/cepis.rb index 7b2cc5a..39cd831 100644 --- a/lib/cepis.rb +++ b/lib/cepis.rb @@ -27,8 +27,11 @@ require_relative "cepis/client" # # begin # address = Cepis.lookup(cep_from_form) -# rescue Cepis::CEPNotFound -# # the CEP does not exist: ask the user for another +# rescue Cepis::CEPNotFound => e +# # the CEP does not exist: ask the user for another. e.faixa, when the +# # API sends it, names the municipality whose numbers surround the one +# # that failed — context for the error, never an address. +# puts "did you mean somewhere in #{e.faixa.localidade}?" if e.faixa # rescue Cepis::Error # # network, server, rate limit: retry later # end diff --git a/lib/cepis/client.rb b/lib/cepis/client.rb index f451061..8480315 100644 --- a/lib/cepis/client.rb +++ b/lib/cepis/client.rb @@ -113,10 +113,15 @@ module Cepis # message: the format already passed local validation, so the server knows # something this client does not. # + # A 404 may carry {CEPNotFound#faixa}: the municipality that owns the band + # of numbers the CEP fell into. It is still a 404 and still an exception — + # the context does not make a nonexistent CEP into an address. See {Faixa}. + # # @param cep [String] masked or unmasked. # @return [Address] # @raise [InvalidCEP] the CEP is not 8 digits (no request is made). - # @raise [CEPNotFound] well-formed, but not in the database. + # @raise [CEPNotFound] well-formed, but not in the database. Carries + # {CEPNotFound#faixa} when the API named a municipality for the band. # @raise [RateLimited] 429 after every attempt. # @raise [ServerError] 5xx after every attempt. # @raise [NetworkError] the request never completed. @@ -125,7 +130,7 @@ module Cepis number = Cepis.normalize_cep(cep) status, body = request("/#{number}") - raise CEPNotFound, "CEP #{number} does not exist in the DNE database" if status == 404 + raise not_found(number, body) if status == 404 raise InvalidCEP, error_message(body, "CEP #{number} rejected") if status == 400 Address.from_json(parse_json(body)) @@ -219,6 +224,38 @@ module Cepis body.to_s.dup.force_encoding(Encoding::UTF_8).scrub end + # The {CEPNotFound} for a 404, with the band read off the body when it is + # there. + # + # Every step of reading it is allowed to fail into `nil`: an absent + # `faixa` is the ordinary case (the API omits it, it never sends null), + # and a body that is not JSON at all is a proxy's doing, not the caller's. + # Neither may promote a definitive, well-understood not-found into an + # {InvalidResponse} — the reason the lookup failed would be replaced by + # the reason the consolation prize failed to parse. + def not_found(number, body) + faixa = begin + data = JSON.parse(decode(body)) + data.is_a?(Hash) ? Faixa.from_json(data["faixa"]) : nil + rescue JSON::ParserError + nil + end + + CEPNotFound.new(not_found_message(number, faixa), faixa: faixa) + end + + # The message names the municipality when the API named one, because + # "does not exist" plus "the numbers around it are Areal/RJ" is what makes + # the log line diagnosable. The counts stay in the message too, so that + # nobody reads the two CEPs as the ends of a contiguous range. + def not_found_message(number, faixa) + base = "CEP #{number} does not exist in the DNE database" + return base if faixa.nil? + + "#{base} (it falls in the band of #{faixa.localidade}/#{faixa.uf}, which has " \ + "#{faixa.ceps} CEP(s) scattered between #{faixa.cep_ini} and #{faixa.cep_fim})" + end + def error_message(body, fallback) data = JSON.parse(decode(body)) data.is_a?(Hash) && data["mensagem"] ? String(data["mensagem"]) : fallback diff --git a/lib/cepis/errors.rb b/lib/cepis/errors.rb index 7a15841..71b8a29 100644 --- a/lib/cepis/errors.rb +++ b/lib/cepis/errors.rb @@ -27,7 +27,25 @@ module Cepis class InvalidCEP < Error; end # The CEP is well-formed but does not exist in the DNE database. - class CEPNotFound < Error; end + class CEPNotFound < Error + # The municipality that owns the band this CEP falls into, as a {Faixa}, + # or `nil` when no municipality owns it — or when the API did not say. + # + # It is context for the failure, not a fallback address: the CEP asked for + # does not exist, and this only names the neighbourhood of numbers it + # would have belonged to. Useful because a CEP that fails is usually a + # guess by someone who knows the city, so the municipality is the one + # thing about it that tends to be right. + # + # Read {Faixa} before showing `cep_ini`/`cep_fim` to anyone: the band is + # sparse, and the pair is not a range of valid CEPs. + attr_reader :faixa + + def initialize(message, faixa: nil) + super(message) + @faixa = faixa + end + end # The request never completed: DNS, refused connection, timeout, TLS. # 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 + "#<Cepis::Faixa #{@localidade}/#{@uf} #{@cep_ini}-#{@cep_fim} #{@ceps} CEPs>" + end + end + # The response of `GET /health`. # # Good for two things: checking that the API answers, and knowing **which diff --git a/lib/cepis/version.rb b/lib/cepis/version.rb index cb88e06..ad38425 100644 --- a/lib/cepis/version.rb +++ b/lib/cepis/version.rb @@ -2,5 +2,5 @@ module Cepis # Version of this library, as reported in the default `User-Agent`. - VERSION = "0.1.0" + VERSION = "0.2.0" end diff --git a/test/cepis_test.rb b/test/cepis_test.rb index 06a6bf6..6fd3353 100644 --- a/test/cepis_test.rb +++ b/test/cepis_test.rb @@ -91,6 +91,67 @@ class LookupTest < ServerTest assert_includes error.message, "99999999" end + def test_not_found_carries_the_band + body = { "erro" => "cep_nao_encontrado", "mensagem" => "CEP não existe na base do DNE", + "faixa" => AREAL_FAIXA } + routes.respond("/25845000", [404, JSON.generate(body), {}]) + error = assert_raises(Cepis::CEPNotFound) { @client.lookup("25845000") } + + assert_equal "Areal", error.faixa.localidade + assert_equal "RJ", error.faixa.uf + assert_equal "3300225", error.faixa.ibge + assert_equal 118, error.faixa.ceps + # The band starts above the CEP that just failed: cep_ini is the smallest + # CEP that exists, not the DNE's published start, which is 25845000. + assert_equal "25845001", error.faixa.cep_ini + assert_equal "25849899", error.faixa.cep_fim + end + + def test_the_band_names_the_municipality_in_the_message + body = { "erro" => "cep_nao_encontrado", "faixa" => AREAL_FAIXA } + routes.respond("/25845000", [404, JSON.generate(body), {}]) + error = assert_raises(Cepis::CEPNotFound) { @client.lookup("25845000") } + + assert_includes error.message, "25845000" + assert_includes error.message, "Areal/RJ" + end + + def test_band_is_absent_when_no_municipality_owns_the_numbers + # The API omits `faixa` in that case — it never sends null. + routes.respond( + "/99999999", + [404, JSON.generate({ "erro" => "cep_nao_encontrado", "mensagem" => "gone" }), {}] + ) + error = assert_raises(Cepis::CEPNotFound) { @client.lookup("99999999") } + + assert_nil error.faixa + end + + def test_malformed_band_does_not_break_the_not_found + ["Areal", {}, { "uf" => "RJ" }, { "localidade" => "" }, [], 7].each do |faixa| + body = { "erro" => "cep_nao_encontrado", "faixa" => faixa } + routes.respond("/99999999", [404, JSON.generate(body), {}]) + error = assert_raises(Cepis::CEPNotFound) { @client.lookup("99999999") } + + assert_nil error.faixa, "expected #{faixa.inspect} to be ignored" + assert_includes error.message, "99999999" + end + end + + def test_unreadable_404_body_is_still_a_clean_not_found + routes.respond("/99999999", [404, "<html>captive portal</html>", {}]) + error = assert_raises(Cepis::CEPNotFound) { @client.lookup("99999999") } + + assert_nil error.faixa + end + + def test_band_never_turns_a_not_found_into_an_address + body = { "erro" => "cep_nao_encontrado", "faixa" => AREAL_FAIXA } + routes.respond("/25845000", [404, JSON.generate(body), {}]) + + assert_raises(Cepis::CEPNotFound) { @client.lookup("25845000") } + end + def test_not_found_is_not_retried routes.respond("/99999999", [404, JSON.generate({ "erro" => "x" }), {}]) assert_raises(Cepis::CEPNotFound) { @client.lookup("99999999") } diff --git a/test/test_helper.rb b/test/test_helper.rb index e12bd9d..48b581d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -24,6 +24,22 @@ SE_ADDRESS = { "siafi" => "7107" }.freeze +# The band of a CEP that does not exist, as the API returns it on the 404. +# +# 25845000 is the DNE's own start for Areal and does not exist: `cep_ini` is +# the smallest CEP that really is there, one number above it. The 118 CEPs are +# scattered across the ~4,900 numbers up to `cep_fim`. +AREAL_FAIXA = { + "localidade" => "Areal", + "uf" => "RJ", + "estado" => "Rio de Janeiro", + "regiao" => "Sudeste", + "ibge" => "3300225", + "cep_ini" => "25845001", + "cep_fim" => "25849899", + "ceps" => 118 +}.freeze + CSV_HEADER = "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado," \ "regiao,ibge,gia,ddd,siafi" |