summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md41
-rw-r--r--lib/cepis.ex50
-rw-r--r--lib/cepis/error.ex12
-rw-r--r--lib/cepis/faixa.ex124
-rw-r--r--mix.exs2
-rw-r--r--test/cepis/lookup_test.exs74
-rw-r--r--test/cepis_test.exs20
-rw-r--r--test/support/server_case.ex20
8 files changed, 335 insertions, 8 deletions
diff --git a/README.md b/README.md
index bb79b2a..837251a 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ Correios data (DNE), refreshed every two weeks.
```elixir
def deps do
- [{:cepis, git: "https://git.cep.is/elixir.git", tag: "v0.1.0"}]
+ [{:cepis, git: "https://git.cep.is/elixir.git", tag: "v0.2.0"}]
end
```
@@ -185,6 +185,45 @@ end
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.
+### When the CEP does not exist
+
+A failing CEP is rarely random: it is usually someone who knows the city and
+guessed the number, most often the `-000`, which 957 municipalities never
+received. So `Cepis.NotFoundError` carries a `faixa` naming the municipality
+that number *would* have belonged to, when there is one:
+
+```elixir
+{:error, %Cepis.NotFoundError{faixa: faixa}} = Cepis.lookup("25845-000")
+
+if faixa do
+ faixa.localidade # "Areal"
+ faixa.uf # "RJ"
+ faixa.ibge # "3300225"
+ faixa.ceps # 118
+ faixa.cep_ini # "25845001"
+ faixa.cep_fim # "25849899"
+end
+```
+
+This is **still a not-found**. The CEP is not in the database, `lookup/2` still
+returns `{:error, _}`, and `faixa` is context for the failure — never an
+address. What it buys you is a better answer to the user: "you are in Areal,
+but not at that number", instead of a dead end.
+
+Two things about it:
+
+- `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.
+- **The range is sparse.** Almost nothing between the two bounds is a valid
+ CEP: Areal has 118 across thousands of numbers. That is what `ceps` is for,
+ and `Cepis.Faixa.density/1` measures it. Never expand the pair into a list of
+ CEPs to try.
+
+`faixa` is `nil` whenever the number falls in a band no municipality owns — the
+API omits the field there rather than sending null — so every caller has to
+handle its absence.
+
## Development
Everything runs through `make`, which shells out to `mix`:
diff --git a/lib/cepis.ex b/lib/cepis.ex
index 249e3f1..95d0579 100644
--- a/lib/cepis.ex
+++ b/lib/cepis.ex
@@ -27,7 +27,7 @@ defmodule Cepis do
the JSON is what you access on the struct. See `Cepis.Address`.
"""
- alias Cepis.{Address, Client, CSV, Health, HTTP}
+ alias Cepis.{Address, Client, CSV, Faixa, Health, HTTP}
alias Cepis.{InvalidCEPError, InvalidResponseError, NotFoundError}
@max_batch 500
@@ -93,11 +93,30 @@ defmodule Cepis do
message: the format already passed local validation, so the server knows
something this client does not.
+ ## When the CEP does not exist
+
+ A failing CEP is rarely random — it is usually someone who knows the city and
+ guessed the number, most often the `-000`, which 957 municipalities never
+ received. So `Cepis.NotFoundError` may carry a `Cepis.Faixa` naming the
+ municipality that number *would* have belonged to:
+
+ {:error, %Cepis.NotFoundError{faixa: faixa}} = Cepis.lookup("25845000")
+
+ if faixa do
+ # "Areal", "RJ" — the CEP is still not in the database
+ {faixa.localidade, faixa.uf}
+ end
+
+ It is still a not-found, and `faixa` is context for the failure rather than
+ an address. It is `nil` whenever the number falls in a band no municipality
+ owns.
+
## Errors
* `Cepis.InvalidCEPError` — not 8 digits (no request is made), or the API
rejected it.
- * `Cepis.NotFoundError` — well-formed, but not in the database.
+ * `Cepis.NotFoundError` — well-formed, but not in the database. May carry
+ the municipality in `faixa`.
* `Cepis.RateLimitError` — 429 after every attempt.
* `Cepis.ServerError` — 5xx after every attempt.
* `Cepis.NetworkError` — the request never completed.
@@ -197,11 +216,14 @@ defmodule Cepis do
@spec health!(Client.t() | keyword()) :: Health.t()
def health!(client \\ []), do: unwrap(health(client))
- defp from_response(404, _body, number) do
+ defp from_response(404, body, number) do
+ faixa = faixa(body)
+
{:error,
%NotFoundError{
cep: number,
- message: "CEP #{number} does not exist in the DNE database"
+ faixa: faixa,
+ message: not_found_message(number, faixa)
}}
end
@@ -213,6 +235,26 @@ defmodule Cepis do
with {:ok, data} <- decode(body), do: {:ok, Address.from_map(data)}
end
+ # A 404 body is a courtesy, not a contract: if it is absent, truncated or not
+ # JSON at all, the not-found still stands on its own. Nothing here may turn a
+ # clean definitive answer into a parse failure.
+ defp faixa(body) do
+ case decode(body) do
+ {:ok, data} -> Faixa.from_map(Map.get(data, "faixa"))
+ {:error, _reason} -> nil
+ end
+ end
+
+ defp not_found_message(number, nil) do
+ "CEP #{number} does not exist in the DNE database"
+ end
+
+ defp not_found_message(number, %Faixa{} = faixa) do
+ "CEP #{number} does not exist in the DNE database — the range belongs to " <>
+ "#{faixa.localidade}/#{faixa.uf}, which has #{faixa.ceps} CEPs between " <>
+ "#{faixa.cep_ini} and #{faixa.cep_fim}"
+ end
+
defp lookup_chunk(client, ceps) do
query = URI.encode_query(%{"ceps" => Enum.map_join(ceps, ",", &to_string/1)})
diff --git a/lib/cepis/error.ex b/lib/cepis/error.ex
index bd86b65..2391c8c 100644
--- a/lib/cepis/error.ex
+++ b/lib/cepis/error.ex
@@ -21,11 +21,19 @@ defmodule Cepis.NotFoundError do
`cep` holds the normalised 8 digits that were looked up. Retrying does not
help: the answer is definitive.
+
+ `faixa` is a `Cepis.Faixa` naming the municipality that CEP *would* have
+ belonged to, or `nil`. It is context for the failure and **not** an address —
+ this is still a not-found, and the CEP is still not in the database. It is
+ `nil` whenever the number falls in a band no municipality owns, which the API
+ signals by omitting the field rather than sending null.
"""
- defexception [:message, :cep]
+ alias Cepis.Faixa
+
+ defexception [:message, :cep, :faixa]
- @type t :: %__MODULE__{message: String.t(), cep: String.t()}
+ @type t :: %__MODULE__{message: String.t(), cep: String.t(), faixa: Faixa.t() | nil}
end
defmodule Cepis.NetworkError do
diff --git a/lib/cepis/faixa.ex b/lib/cepis/faixa.ex
new file mode 100644
index 0000000..ab85e8d
--- /dev/null
+++ b/lib/cepis/faixa.ex
@@ -0,0 +1,124 @@
+defmodule Cepis.Faixa do
+ @moduledoc """
+ The municipality a CEP that does **not** exist would have belonged to.
+
+ It arrives on `Cepis.NotFoundError`, and nowhere else. **It is context for an
+ error, not an address**: the lookup failed, the CEP is not in the DNE, and
+ nothing here says otherwise. Using it as a fallback address is the one wrong
+ use.
+
+ It is worth having because a failing CEP is rarely random — it is almost
+ always someone who knows the city and guessed the number, most often the
+ `-000`, which 957 municipalities never received. Naming the municipality
+ turns "does not exist" into "you are in Areal, but not at that number", which
+ is the difference between a dead end and a form the user can fix.
+
+ **Field names are the API's own, in Portuguese**, as everywhere else in this
+ library: `localidade`, `uf`, `estado`, `regiao`, `ibge`, `cep_ini`,
+ `cep_fim`, `ceps`.
+
+ > #### The range is sparse {: .warning}
+ >
+ > `cep_ini` and `cep_fim` are the smallest and the largest CEP that
+ > **actually exist** in that municipality — not the bounds the Correios
+ > publish. The DNE's own start for Areal is `25845000`, which is exactly the
+ > CEP that just failed; echoing it back would be useless. The numbers between
+ > the two bounds are mostly not CEPs at all: Areal's 118 are scattered across
+ > thousands of them. That is what `ceps` is for, and what `density/1`
+ > measures. Never expand the pair into a list of valid CEPs.
+
+ The API **omits** `faixa`, never sends it as null, when the CEP falls in a
+ numeric band no municipality owns. So `nil` is an ordinary answer, not a
+ failure, and every caller has to handle it.
+ """
+
+ defstruct localidade: "",
+ uf: "",
+ estado: "",
+ regiao: "",
+ ibge: "",
+ cep_ini: "",
+ cep_fim: "",
+ ceps: 0
+
+ @type t :: %__MODULE__{
+ localidade: String.t(),
+ uf: String.t(),
+ estado: String.t(),
+ regiao: String.t(),
+ ibge: String.t(),
+ cep_ini: String.t(),
+ cep_fim: String.t(),
+ ceps: non_neg_integer()
+ }
+
+ @doc """
+ The share of the numbers between `cep_ini` and `cep_fim` that are real CEPs.
+
+ About `0.024` for Areal — 118 CEPs over the 4899 numbers between `25845001`
+ and `25849899`. It exists so the sparsity is measurable instead of being a
+ footnote nobody reads before iterating over the interval:
+
+ if Cepis.Faixa.density(faixa) < 0.5 do
+ # guessing another number in the range is not going to work
+ suggest_search_by_street(faixa.localidade, faixa.uf)
+ end
+
+ Returns `0.0` when the bounds are not two readable numbers.
+ """
+ @spec density(t()) :: float()
+ def density(%__MODULE__{ceps: ceps, cep_ini: cep_ini, cep_fim: cep_fim}) do
+ with {first, ""} <- Integer.parse(cep_ini),
+ {last, ""} <- Integer.parse(cep_fim),
+ true <- last >= first do
+ ceps / (last - first + 1)
+ else
+ _ -> 0.0
+ end
+ end
+
+ @doc false
+ @spec from_map(term()) :: t() | nil
+ def from_map(%{"localidade" => localidade} = data)
+ when is_binary(localidade) and localidade != "" do
+ %__MODULE__{
+ localidade: localidade,
+ uf: text(data, "uf"),
+ estado: text(data, "estado"),
+ regiao: text(data, "regiao"),
+ ibge: text(data, "ibge"),
+ cep_ini: text(data, "cep_ini"),
+ cep_fim: text(data, "cep_fim"),
+ ceps: count(data, "ceps")
+ }
+ end
+
+ def from_map(_), do: nil
+
+ defp text(data, key) do
+ case Map.get(data, key) do
+ value when is_binary(value) -> value
+ value when is_number(value) -> to_string(value)
+ _ -> ""
+ end
+ end
+
+ defp count(data, key) do
+ case Map.get(data, key) do
+ value when is_integer(value) ->
+ value
+
+ value when is_float(value) ->
+ trunc(value)
+
+ value when is_binary(value) ->
+ case Integer.parse(value) do
+ {number, _} -> number
+ :error -> 0
+ end
+
+ _ ->
+ 0
+ end
+ end
+end
diff --git a/mix.exs b/mix.exs
index 8a1625c..d67e5d4 100644
--- a/mix.exs
+++ b/mix.exs
@@ -1,7 +1,7 @@
defmodule Cepis.MixProject do
use Mix.Project
- @version "0.1.0"
+ @version "0.2.0"
@source_url "https://git.cep.is/elixir.git"
def project do
diff --git a/test/cepis/lookup_test.exs b/test/cepis/lookup_test.exs
index b68ecbe..39d9d31 100644
--- a/test/cepis/lookup_test.exs
+++ b/test/cepis/lookup_test.exs
@@ -87,6 +87,80 @@ defmodule Cepis.LookupTest do
assert Exception.message(error) =~ "99999999"
end
+ test "a not-found names the municipality the CEP would belong to", %{
+ server: server,
+ client: client
+ } do
+ body = not_found_fixture(%{"faixa" => faixa_fixture()})
+ FakeServer.respond(server, "/25845000", {404, json(body), []})
+
+ assert {:error, %Cepis.NotFoundError{cep: "25845000", faixa: faixa} = error} =
+ Cepis.lookup("25845-000", client)
+
+ assert faixa.localidade == "Areal"
+ assert faixa.uf == "RJ"
+ assert faixa.estado == "Rio de Janeiro"
+ assert faixa.regiao == "Sudeste"
+ assert faixa.ibge == "3300225"
+ assert faixa.cep_ini == "25845001"
+ assert faixa.cep_fim == "25849899"
+ assert faixa.ceps == 118
+
+ assert Exception.message(error) =~ "Areal/RJ"
+ assert Exception.message(error) =~ "25845000 does not exist"
+ end
+
+ test "a not-found in a band no municipality owns has no faixa", %{
+ server: server,
+ client: client
+ } do
+ FakeServer.respond(server, "/99999999", {404, json(not_found_fixture()), []})
+
+ assert {:error, %Cepis.NotFoundError{faixa: nil} = error} = Cepis.lookup("99999999", client)
+ assert Exception.message(error) == "CEP 99999999 does not exist in the DNE database"
+ end
+
+ test "a malformed faixa does not become a parse failure", %{server: server, client: client} do
+ bodies = [
+ not_found_fixture(%{"faixa" => %{}}),
+ not_found_fixture(%{"faixa" => %{"uf" => "RJ"}}),
+ not_found_fixture(%{"faixa" => %{"localidade" => ""}}),
+ not_found_fixture(%{"faixa" => "Areal"}),
+ # the API omits the field rather than nulling it, but a null must not
+ # cost the caller the error either
+ not_found_fixture(%{"faixa" => :null})
+ ]
+
+ for {body, index} <- Enum.with_index(bodies) do
+ path = "/2584500#{index}"
+ FakeServer.respond(server, path, {404, json(body), []})
+
+ assert {:error, %Cepis.NotFoundError{faixa: nil}} =
+ Cepis.lookup(String.trim_leading(path, "/"), client)
+ end
+ end
+
+ test "a 404 body that is not JSON is still a clean not-found", %{
+ server: server,
+ client: client
+ } do
+ FakeServer.respond(server, "/99999999", {404, "<html>gateway</html>", []})
+
+ assert {:error, %Cepis.NotFoundError{cep: "99999999", faixa: nil}} =
+ Cepis.lookup("99999999", client)
+ end
+
+ test "a faixa keeps whatever the API sends, even partially", %{server: server, client: client} do
+ body = not_found_fixture(%{"faixa" => %{"localidade" => "Areal", "ceps" => "118"}})
+ FakeServer.respond(server, "/25845000", {404, json(body), []})
+
+ assert {:error, %Cepis.NotFoundError{faixa: faixa}} = Cepis.lookup("25845000", client)
+ assert faixa.localidade == "Areal"
+ assert faixa.ceps == 118
+ assert faixa.cep_ini == ""
+ assert Cepis.Faixa.density(faixa) == 0.0
+ end
+
test "a 404 is never retried", %{server: server, client: client} do
FakeServer.respond(server, "/99999999", {404, json(%{"erro" => "cep_nao_encontrado"}), []})
diff --git a/test/cepis_test.exs b/test/cepis_test.exs
index a6bedb1..df9256a 100644
--- a/test/cepis_test.exs
+++ b/test/cepis_test.exs
@@ -67,6 +67,26 @@ defmodule CepisTest do
end
end
+ describe "Cepis.Faixa.density/1" do
+ test "measures how sparse the range really is" do
+ faixa = %Cepis.Faixa{cep_ini: "25845001", cep_fim: "25849899", ceps: 118}
+
+ assert_in_delta Cepis.Faixa.density(faixa), 0.024, 0.001
+ assert Cepis.Faixa.density(faixa) < 1.0
+ end
+
+ test "is 0.0 when the bounds are not two readable numbers" do
+ assert Cepis.Faixa.density(%Cepis.Faixa{}) == 0.0
+ assert Cepis.Faixa.density(%Cepis.Faixa{cep_ini: "25845001", ceps: 118}) == 0.0
+ assert Cepis.Faixa.density(%Cepis.Faixa{cep_ini: "x", cep_fim: "y", ceps: 118}) == 0.0
+ end
+
+ test "is 0.0 when the bounds are inverted" do
+ faixa = %Cepis.Faixa{cep_ini: "25849899", cep_fim: "25845001", ceps: 118}
+ assert Cepis.Faixa.density(faixa) == 0.0
+ end
+ end
+
describe "Cepis.Address.formatted_cep/1" do
test "adds the mask" do
assert Cepis.Address.formatted_cep(%Cepis.Address{cep: "01001000"}) == "01001-000"
diff --git a/test/support/server_case.ex b/test/support/server_case.ex
index a64bb05..8af08cb 100644
--- a/test/support/server_case.ex
+++ b/test/support/server_case.ex
@@ -44,8 +44,28 @@ defmodule Cepis.ServerCase do
@csv_header "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"
+ @faixa %{
+ "localidade" => "Areal",
+ "uf" => "RJ",
+ "estado" => "Rio de Janeiro",
+ "regiao" => "Sudeste",
+ "ibge" => "3300225",
+ "cep_ini" => "25845001",
+ "cep_fim" => "25849899",
+ "ceps" => 118
+ }
+
def address_fixture(extra \\ %{}), do: Map.merge(@address, extra)
+ def faixa_fixture(extra \\ %{}), do: Map.merge(@faixa, extra)
+
+ def not_found_fixture(extra \\ %{}) do
+ Map.merge(
+ %{"erro" => "cep_nao_encontrado", "mensagem" => "CEP não existe na base do DNE"},
+ extra
+ )
+ end
+
def json(term), do: IO.iodata_to_binary(:json.encode(term))
def csv(rows), do: Enum.join([@csv_header | rows], "\n") <> "\n"