diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/cepis.ex | 50 | ||||
| -rw-r--r-- | lib/cepis/error.ex | 12 | ||||
| -rw-r--r-- | lib/cepis/faixa.ex | 124 |
3 files changed, 180 insertions, 6 deletions
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 |