summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md44
-rw-r--r--lib/cepis.rb7
-rw-r--r--lib/cepis/client.rb41
-rw-r--r--lib/cepis/errors.rb20
-rw-r--r--lib/cepis/models.rb85
-rw-r--r--lib/cepis/version.rb2
-rw-r--r--test/cepis_test.rb61
-rw-r--r--test/test_helper.rb16
8 files changed, 270 insertions, 6 deletions
diff --git a/README.md b/README.md
index 017fe89..0085f1c 100644
--- a/README.md
+++ b/README.md
@@ -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"