diff options
| -rw-r--r-- | README.md | 41 | ||||
| -rw-r--r-- | lib/cepis.ex | 50 | ||||
| -rw-r--r-- | lib/cepis/error.ex | 12 | ||||
| -rw-r--r-- | lib/cepis/faixa.ex | 124 | ||||
| -rw-r--r-- | mix.exs | 2 | ||||
| -rw-r--r-- | test/cepis/lookup_test.exs | 74 | ||||
| -rw-r--r-- | test/cepis_test.exs | 20 | ||||
| -rw-r--r-- | test/support/server_case.ex | 20 |
8 files changed, 335 insertions, 8 deletions
@@ -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 @@ -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" |