summaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:19:34 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:19:34 -0300
commit075683ff44a254e99adbad8a0372ef80ff06151a (patch)
tree648289c8485afc6f003a3f719fb937c21f149311 /lib
downloadelixir-0785ed6b367d134b192c89fbf840532f172a3b4a.tar.gz
Initial importv0.1.0
Diffstat (limited to 'lib')
-rw-r--r--lib/cepis.ex277
-rw-r--r--lib/cepis/address.ex101
-rw-r--r--lib/cepis/client.ex114
-rw-r--r--lib/cepis/csv.ex65
-rw-r--r--lib/cepis/error.ex157
-rw-r--r--lib/cepis/geo.ex85
-rw-r--r--lib/cepis/health.ex64
-rw-r--r--lib/cepis/http.ex197
8 files changed, 1060 insertions, 0 deletions
diff --git a/lib/cepis.ex b/lib/cepis.ex
new file mode 100644
index 0000000..249e3f1
--- /dev/null
+++ b/lib/cepis.ex
@@ -0,0 +1,277 @@
+defmodule Cepis do
+ @moduledoc """
+ Elixir client for the public CEP API at [cep.is](https://cep.is).
+
+ Official Correios data (DNE), refreshed every two weeks.
+
+ {:ok, address} = Cepis.lookup("01001-000")
+ address.logradouro
+ #=> "Praça da Sé"
+
+ Every function returns `{:ok, result}` or `{:error, error}`, and every one has
+ a bang variant that returns the result directly and raises instead. The error
+ is always one of the structs in `t:Cepis.Error.t/0`, so `{:error, error}` is
+ the single handler that catches everything.
+
+ The last argument of every call is the configuration: a keyword list, or a
+ `%Cepis.Client{}` built once with `Cepis.Client.new/1` and reused. Omit it
+ for the defaults.
+
+ client = Cepis.Client.new(base_url: "http://localhost:8080")
+ {:ok, address} = Cepis.lookup("01001000", client)
+
+ ## Field names
+
+ The API surface here is English, but the **data fields keep the API's own
+ Portuguese names** — `logradouro`, `bairro`, `localidade`. What you read in
+ the JSON is what you access on the struct. See `Cepis.Address`.
+ """
+
+ alias Cepis.{Address, Client, CSV, Health, HTTP}
+ alias Cepis.{InvalidCEPError, InvalidResponseError, NotFoundError}
+
+ @max_batch 500
+ @cep ~r/\A[0-9]{8}\z/
+
+ @doc """
+ The most CEPs the API accepts in one batch request: 500.
+
+ `lookup_many/2` chunks longer lists at this size on its own.
+ """
+ @spec max_batch() :: pos_integer()
+ def max_batch, do: @max_batch
+
+ @doc """
+ Return the CEP's 8 digits, accepting the mask.
+
+ `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`.
+
+ Returns `{:error, %Cepis.InvalidCEPError{}}` unless exactly 8 digits remain.
+ `lookup/2` runs this **before** any request: a 7-digit CEP is the caller's
+ bug, and spending a round trip for the server to say so wastes both sides'
+ time.
+
+ iex> Cepis.normalize_cep("01001-000")
+ {:ok, "01001000"}
+
+ iex> {:error, error} = Cepis.normalize_cep("123")
+ iex> error.__struct__
+ Cepis.InvalidCEPError
+ """
+ @spec normalize_cep(term()) :: {:ok, String.t()} | {:error, InvalidCEPError.t()}
+ def normalize_cep(value) when is_binary(value) do
+ cleaned = value |> String.trim() |> String.replace(["-", ".", " "], "")
+
+ if Regex.match?(@cep, cleaned) do
+ {:ok, cleaned}
+ else
+ {:error, %InvalidCEPError{message: "CEP must be 8 digits, got #{inspect(value)}"}}
+ end
+ end
+
+ def normalize_cep(value) do
+ {:error, %InvalidCEPError{message: "CEP must be a string, got #{inspect(value)}"}}
+ end
+
+ @doc """
+ Same as `normalize_cep/1`, but returns the digits and raises on a bad CEP.
+
+ iex> Cepis.normalize_cep!(" 01001.000 ")
+ "01001000"
+ """
+ @spec normalize_cep!(term()) :: String.t()
+ def normalize_cep!(value), do: unwrap(normalize_cep(value))
+
+ @doc """
+ Look up a single CEP.
+
+ {:ok, address} = Cepis.lookup("01310-100")
+ address.localidade
+ #=> "São Paulo"
+
+ A `400` from the API becomes `Cepis.InvalidCEPError` carrying the API's own
+ message: the format already passed local validation, so the server knows
+ something this client does not.
+
+ ## 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.RateLimitError` — 429 after every attempt.
+ * `Cepis.ServerError` — 5xx after every attempt.
+ * `Cepis.NetworkError` — the request never completed.
+ * `Cepis.InvalidResponseError` — a success status with an unparseable body.
+ """
+ @spec lookup(String.t(), Client.t() | keyword()) ::
+ {:ok, Address.t()} | {:error, Cepis.Error.t()}
+ def lookup(cep, client \\ []) do
+ with {:ok, number} <- normalize_cep(cep),
+ {:ok, status, body, _headers} <- HTTP.request(Client.new(client), "/" <> number) do
+ from_response(status, body, number)
+ end
+ end
+
+ @doc """
+ Same as `lookup/2`, but returns the `Cepis.Address` and raises on failure.
+ """
+ @spec lookup!(String.t(), Client.t() | keyword()) :: Address.t()
+ def lookup!(cep, client \\ []), do: unwrap(lookup(cep, client))
+
+ @doc """
+ Look up many CEPs in one batch.
+
+ ceps = ["01001000", "99999999", "20040020"]
+ {:ok, addresses} = Cepis.lookup_many(ceps)
+
+ Enum.zip(ceps, addresses)
+ #=> [{"01001000", %Cepis.Address{}}, {"99999999", nil}, {"20040020", %Cepis.Address{}}]
+
+ The list comes back with **the same length and in the same order** as the
+ input. A CEP that does not exist becomes `nil` at its position — it does not
+ vanish from the list, and it does not fail the call. That is the right
+ difference from `lookup/2`: in a batch of a thousand, one bad CEP must not
+ cost the other nine hundred and ninety-nine.
+
+ A malformed CEP also becomes `nil`, for the same reason. If you need to know
+ *which* one was malformed, validate first with `normalize_cep/1`.
+
+ Input is chunked into blocks of 500 (the API's limit) automatically. Do not
+ loop over `lookup/2` instead: a loop of 500 calls pays 500 round trips, the
+ batch pays one.
+
+ > #### Batches carry no coordinate {: .warning}
+ >
+ > The endpoint behind this is CSV, and the CSV has no `geo` column. Every
+ > `Cepis.Address` returned here has `geo: nil`, including for CEPs that do
+ > have one. Need `geo`? Use `lookup/2`, one at a time.
+ """
+ @spec lookup_many([String.t()], Client.t() | keyword()) ::
+ {:ok, [Address.t() | nil]} | {:error, Cepis.Error.t()}
+ def lookup_many(ceps, client \\ [])
+
+ def lookup_many([], _client), do: {:ok, []}
+
+ def lookup_many(ceps, client) when is_list(ceps) do
+ config = Client.new(client)
+
+ ceps
+ |> Enum.chunk_every(@max_batch)
+ |> Enum.reduce_while({:ok, []}, fn chunk, {:ok, acc} ->
+ case lookup_chunk(config, chunk) do
+ {:ok, rows} -> {:cont, {:ok, acc ++ rows}}
+ {:error, _reason} = error -> {:halt, error}
+ end
+ end)
+ end
+
+ @doc """
+ Same as `lookup_many/2`, but returns the list and raises on failure.
+ """
+ @spec lookup_many!([String.t()], Client.t() | keyword()) :: [Address.t() | nil]
+ def lookup_many!(ceps, client \\ []), do: unwrap(lookup_many(ceps, client))
+
+ @doc """
+ Query `GET /health`: does the API answer, and with which database.
+
+ {:ok, health} = Cepis.health()
+ Cepis.Health.ok?(health)
+ #=> true
+
+ An API reporting itself degraded is **not** a failure here — that arrives in
+ `health.status`, and checking health is exactly when you want the diagnosis
+ rather than an error. Network failures still fail.
+ """
+ @spec health(Client.t() | keyword()) :: {:ok, Health.t()} | {:error, Cepis.Error.t()}
+ def health(client \\ []) do
+ with {:ok, _status, body, _headers} <-
+ HTTP.request(Client.new(client), "/health", [200, 503]),
+ {:ok, data} <- decode(body) do
+ {:ok, Health.from_map(data)}
+ end
+ end
+
+ @doc """
+ Same as `health/1`, but returns the `Cepis.Health` and raises on failure.
+ """
+ @spec health!(Client.t() | keyword()) :: Health.t()
+ def health!(client \\ []), do: unwrap(health(client))
+
+ defp from_response(404, _body, number) do
+ {:error,
+ %NotFoundError{
+ cep: number,
+ message: "CEP #{number} does not exist in the DNE database"
+ }}
+ end
+
+ defp from_response(400, body, number) do
+ {:error, %InvalidCEPError{message: api_message(body, "CEP #{number} rejected")}}
+ end
+
+ defp from_response(_status, body, _number) do
+ with {:ok, data} <- decode(body), do: {:ok, Address.from_map(data)}
+ end
+
+ defp lookup_chunk(client, ceps) do
+ query = URI.encode_query(%{"ceps" => Enum.map_join(ceps, ",", &to_string/1)})
+
+ with {:ok, _status, body, _headers} <- HTTP.request(client, "/csv?" <> query) do
+ rows =
+ body
+ |> CSV.parse_with_header()
+ |> Enum.map(&row_to_address/1)
+
+ {:ok, pad(rows, length(ceps))}
+ end
+ end
+
+ defp row_to_address(row) do
+ if row |> Map.get("localidade", "") |> String.trim() == "" do
+ nil
+ else
+ Address.from_map(row)
+ end
+ end
+
+ defp pad(rows, size) when length(rows) == size, do: rows
+ defp pad(rows, size), do: Enum.take(rows ++ List.duplicate(nil, size), size)
+
+ defp decode(body) do
+ case :json.decode(body) do
+ data when is_map(data) ->
+ {:ok, data}
+
+ other ->
+ {:error,
+ %InvalidResponseError{
+ message: "expected a JSON object, got #{inspect(other, limit: 5)}"
+ }}
+ end
+ rescue
+ _ -> unreadable(body)
+ catch
+ _kind, _value -> unreadable(body)
+ end
+
+ defp unreadable(body) do
+ excerpt = binary_part(body, 0, min(120, byte_size(body)))
+
+ {:error,
+ %InvalidResponseError{
+ message:
+ "response is not valid JSON — something between you and the API is " <>
+ "answering instead of it: #{inspect(excerpt)}"
+ }}
+ end
+
+ defp api_message(body, fallback) do
+ case decode(body) do
+ {:ok, %{"mensagem" => message}} when is_binary(message) and message != "" -> message
+ _ -> fallback
+ end
+ end
+
+ defp unwrap({:ok, value}), do: value
+ defp unwrap({:error, error}), do: raise(error)
+end
diff --git a/lib/cepis/address.ex b/lib/cepis/address.ex
new file mode 100644
index 0000000..0563912
--- /dev/null
+++ b/lib/cepis/address.ex
@@ -0,0 +1,101 @@
+defmodule Cepis.Address do
+ @moduledoc """
+ A CEP and everything the database knows about it.
+
+ **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`,
+ `localidade`. Everything else in this library is English; the fields are the
+ one deliberate exception. The reason is fidelity: what you read in the JSON
+ at <https://cep.is/01001000> is exactly what you access on the struct.
+
+ Fields the database does not have for that CEP come back as empty strings,
+ never `nil` — that is what the API returns, and changing it here would create
+ a difference between reading the JSON and reading this library.
+
+ `geo` is the exception: it is `nil` when there is no coordinate. There the
+ difference is real, because the API **omits** the whole field in that case
+ (about 43% of CEPs) rather than sending null.
+
+ `raw` holds the decoded response exactly as it arrived, uninterpreted, so a
+ field the API grows before this library is updated is not lost.
+ """
+
+ alias Cepis.Geo
+
+ defstruct cep: "",
+ logradouro: "",
+ complemento: "",
+ unidade: "",
+ bairro: "",
+ localidade: "",
+ uf: "",
+ estado: "",
+ regiao: "",
+ ibge: "",
+ gia: "",
+ ddd: "",
+ siafi: "",
+ geo: nil,
+ raw: %{}
+
+ @type t :: %__MODULE__{
+ cep: String.t(),
+ logradouro: String.t(),
+ complemento: String.t(),
+ unidade: String.t(),
+ bairro: String.t(),
+ localidade: String.t(),
+ uf: String.t(),
+ estado: String.t(),
+ regiao: String.t(),
+ ibge: String.t(),
+ gia: String.t(),
+ ddd: String.t(),
+ siafi: String.t(),
+ geo: Geo.t() | nil,
+ raw: map()
+ }
+
+ @doc """
+ The CEP with its mask: `"01001-000"`.
+
+ The native contract always returns it unmasked; formatting is presentation,
+ and it lives here so it does not become a repeated interpolation in every
+ project that uses this. A CEP that is not 8 characters is returned unchanged.
+ """
+ @spec formatted_cep(t()) :: String.t()
+ def formatted_cep(%__MODULE__{cep: <<prefix::binary-size(5), suffix::binary-size(3)>>}) do
+ prefix <> "-" <> suffix
+ end
+
+ def formatted_cep(%__MODULE__{cep: cep}), do: cep
+
+ @doc false
+ @spec from_map(map()) :: t()
+ def from_map(data) when is_map(data) do
+ %__MODULE__{
+ cep: text(data, "cep"),
+ logradouro: text(data, "logradouro"),
+ complemento: text(data, "complemento"),
+ unidade: text(data, "unidade"),
+ bairro: text(data, "bairro"),
+ localidade: text(data, "localidade"),
+ uf: text(data, "uf"),
+ estado: text(data, "estado"),
+ regiao: text(data, "regiao"),
+ ibge: text(data, "ibge"),
+ gia: text(data, "gia"),
+ ddd: text(data, "ddd"),
+ siafi: text(data, "siafi"),
+ geo: Geo.from_map(Map.get(data, "geo")),
+ raw: data
+ }
+ end
+
+ 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
+end
diff --git a/lib/cepis/client.ex b/lib/cepis/client.ex
new file mode 100644
index 0000000..a5e0d58
--- /dev/null
+++ b/lib/cepis/client.ex
@@ -0,0 +1,114 @@
+defmodule Cepis.Client do
+ @moduledoc """
+ Configuration for the cep.is API.
+
+ Every function in `Cepis` takes a client as its last argument. Passing a
+ keyword list builds one on the fly; building a `%Cepis.Client{}` once with
+ `new/1` and reusing it is equivalent and slightly cheaper. The struct is
+ plain data with no process behind it, so it is safe to share across
+ processes.
+
+ client = Cepis.Client.new(base_url: "http://localhost:8080", timeout: 5_000)
+ {:ok, address} = Cepis.lookup("01001000", client)
+
+ **All durations are in milliseconds**, as everywhere else in Elixir.
+
+ ## Options
+
+ * `:base_url` — where the API lives, default `"https://cep.is"`. Point it
+ at a local instance (the API repo brings one up on
+ `http://localhost:8080` with `just up`) or at a test server. It is the
+ only option that usually changes between environments. Trailing slashes
+ are stripped, so it cannot produce doubled separators that some proxies
+ normalise and others 404.
+
+ * `:timeout` — milliseconds for one attempt, default `10_000`. This is
+ `:httpc`'s per-request timeout, used for the connect phase too; it is not
+ a cap on the whole call across retries. That cap is `:total_timeout`.
+
+ * `:max_attempts` — how many attempts in total, counting the first, default
+ `3`. `1` disables retrying. The default covers the common case (one
+ transient failure) without turning a real outage into a long wait.
+
+ * `:backoff_base` — base of the exponential wait, default `500`. The actual
+ wait is drawn from `[0, base * 2^(n-1)]` — *full jitter*. The randomness
+ is not decoration: without it, N clients that fail together come back
+ together and reproduce the spike that took the service down.
+
+ * `:backoff_max` — cap on each individual wait, default `8_000`. It applies
+ to a server-sent `Retry-After` too, or a `Retry-After: 3600` from a
+ misconfigured proxy would park the caller for an hour.
+
+ * `:total_timeout` — cap on the whole call, attempts and waits included,
+ default `nil` (only `:max_attempts` bounds it). Set it in code serving a
+ web request: without it the worst case is `max_attempts * timeout` plus
+ the sum of the backoffs.
+
+ * `:user_agent` — overrides the `User-Agent`. The default identifies the
+ library and its version, which is what lets the server diagnose an old
+ client misbehaving.
+ """
+
+ @default_base_url "https://cep.is"
+ @version Mix.Project.config()[:version]
+
+ defstruct base_url: @default_base_url,
+ timeout: 10_000,
+ max_attempts: 3,
+ backoff_base: 500,
+ backoff_max: 8_000,
+ total_timeout: nil,
+ user_agent: nil
+
+ @type t :: %__MODULE__{
+ base_url: String.t(),
+ timeout: pos_integer(),
+ max_attempts: pos_integer(),
+ backoff_base: non_neg_integer(),
+ backoff_max: non_neg_integer(),
+ total_timeout: pos_integer() | nil,
+ user_agent: String.t()
+ }
+
+ @doc """
+ The API's address when none is configured: `"https://cep.is"`.
+ """
+ @spec default_base_url() :: String.t()
+ def default_base_url, do: @default_base_url
+
+ @doc """
+ Build a client from options, or return one that is already built.
+
+ See the module documentation for every option. Raises `ArgumentError` for an
+ impossible configuration and `KeyError` for an unknown option.
+
+ iex> Cepis.Client.new(base_url: "http://localhost:8080/").base_url
+ "http://localhost:8080"
+ """
+ @spec new(t() | keyword()) :: t()
+ def new(%__MODULE__{} = client), do: client
+
+ def new(opts) when is_list(opts) do
+ client = struct!(__MODULE__, opts)
+
+ if client.max_attempts < 1 do
+ raise ArgumentError, "max_attempts must be >= 1, got #{inspect(client.max_attempts)}"
+ end
+
+ if client.timeout <= 0 do
+ raise ArgumentError, "timeout must be > 0, got #{inspect(client.timeout)}"
+ end
+
+ %{
+ client
+ | base_url: String.trim_trailing(client.base_url, "/"),
+ user_agent: client.user_agent || default_user_agent()
+ }
+ end
+
+ @doc """
+ The `User-Agent` sent when none is configured.
+ """
+ @spec default_user_agent() :: String.t()
+ def default_user_agent, do: "cepis-elixir/#{@version} (+https://cep.is)"
+end
diff --git a/lib/cepis/csv.ex b/lib/cepis/csv.ex
new file mode 100644
index 0000000..58b554f
--- /dev/null
+++ b/lib/cepis/csv.ex
@@ -0,0 +1,65 @@
+defmodule Cepis.CSV do
+ @moduledoc false
+
+ @spec parse_with_header(binary()) :: [%{optional(String.t()) => String.t()}]
+ def parse_with_header(text) do
+ case parse(text) do
+ [] -> []
+ [header | rows] -> Enum.map(rows, &zip(header, &1))
+ end
+ end
+
+ @spec parse(binary()) :: [[String.t()]]
+ def parse(text) do
+ text
+ |> strip_bom()
+ |> outside([], [], [])
+ |> Enum.reject(&(&1 == [""]))
+ end
+
+ defp zip(header, row) do
+ values = List.to_tuple(row)
+ width = tuple_size(values)
+
+ header
+ |> Enum.with_index()
+ |> Map.new(fn {key, index} ->
+ {key, if(index < width, do: elem(values, index), else: "")}
+ end)
+ end
+
+ defp strip_bom(<<0xEF, 0xBB, 0xBF, rest::binary>>), do: rest
+ defp strip_bom(text), do: text
+
+ defp outside(<<>>, field, row, rows), do: emit(field, row, rows)
+
+ defp outside(<<?", rest::binary>>, [], row, rows), do: inside(rest, [], row, rows)
+
+ defp outside(<<?,, rest::binary>>, field, row, rows),
+ do: outside(rest, [], [flush(field) | row], rows)
+
+ defp outside(<<?\r, ?\n, rest::binary>>, field, row, rows),
+ do: outside(rest, [], [], [Enum.reverse([flush(field) | row]) | rows])
+
+ defp outside(<<?\n, rest::binary>>, field, row, rows),
+ do: outside(rest, [], [], [Enum.reverse([flush(field) | row]) | rows])
+
+ defp outside(<<char, rest::binary>>, field, row, rows),
+ do: outside(rest, [field, char], row, rows)
+
+ defp inside(<<>>, field, row, rows), do: emit(field, row, rows)
+
+ defp inside(<<?", ?", rest::binary>>, field, row, rows),
+ do: inside(rest, [field, ?"], row, rows)
+
+ defp inside(<<?", rest::binary>>, field, row, rows), do: outside(rest, field, row, rows)
+
+ defp inside(<<char, rest::binary>>, field, row, rows),
+ do: inside(rest, [field, char], row, rows)
+
+ defp emit(field, row, rows) do
+ Enum.reverse([Enum.reverse([flush(field) | row]) | rows])
+ end
+
+ defp flush(field), do: IO.iodata_to_binary(field)
+end
diff --git a/lib/cepis/error.ex b/lib/cepis/error.ex
new file mode 100644
index 0000000..bd86b65
--- /dev/null
+++ b/lib/cepis/error.ex
@@ -0,0 +1,157 @@
+defmodule Cepis.InvalidCEPError do
+ @moduledoc """
+ The CEP is not 8 digits.
+
+ Usually produced **without** a request ever being made — the client checks
+ the format before going to the network. It is also what a `400` from the API
+ becomes, carrying the API's own message: the format already passed local
+ validation, so the server knows something this client does not.
+
+ Retrying does not help.
+ """
+
+ defexception [:message]
+
+ @type t :: %__MODULE__{message: String.t()}
+end
+
+defmodule Cepis.NotFoundError do
+ @moduledoc """
+ The CEP is well-formed but does not exist in the DNE database.
+
+ `cep` holds the normalised 8 digits that were looked up. Retrying does not
+ help: the answer is definitive.
+ """
+
+ defexception [:message, :cep]
+
+ @type t :: %__MODULE__{message: String.t(), cep: String.t()}
+end
+
+defmodule Cepis.NetworkError do
+ @moduledoc """
+ The request never completed: DNS, refused connection, timeout, TLS.
+
+ `reason` is the underlying `:httpc` reason. Produced only after every
+ configured attempt has failed, so retrying immediately is unlikely to help —
+ reschedule instead.
+ """
+
+ defexception [:message, :reason]
+
+ @type t :: %__MODULE__{message: String.t(), reason: term()}
+end
+
+defmodule Cepis.ServerError do
+ @moduledoc """
+ The API answered 5xx on every attempt.
+
+ `status` holds the HTTP status seen on the last attempt. Retrying later may
+ help; the client already retried on its own before returning this.
+ """
+
+ defexception [:message, :status]
+
+ @type t :: %__MODULE__{message: String.t(), status: pos_integer()}
+end
+
+defmodule Cepis.RateLimitError do
+ @moduledoc """
+ The API (or the edge in front of it) answered 429 on every attempt.
+
+ `retry_after` is what the server asked for, **in milliseconds**, or `nil` if
+ it did not say. The client already honoured that value across the attempts it
+ made; it is exposed here for callers who want to reschedule the work, e.g.
+ with `Process.send_after/3`.
+ """
+
+ defexception [:message, :retry_after]
+
+ @type t :: %__MODULE__{message: String.t(), retry_after: non_neg_integer() | nil}
+end
+
+defmodule Cepis.InvalidResponseError do
+ @moduledoc """
+ A success status came back with a body that cannot be parsed.
+
+ In practice this 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.
+ """
+
+ defexception [:message]
+
+ @type t :: %__MODULE__{message: String.t()}
+end
+
+defmodule Cepis.Error do
+ @moduledoc """
+ The error surface, and how to tell the two kinds apart.
+
+ Every function in `Cepis` returns `{:ok, result}` or `{:error, error}`, where
+ `error` is one of the exception structs listed in `t:t/0`. Matching on
+ `{:error, error}` is therefore the one handler that catches everything; the
+ bang variants raise the same structs instead.
+
+ Below that, the split is by **the decision the caller has to make**, not by
+ the HTTP status that caused it:
+
+ - `Cepis.InvalidCEPError` and `Cepis.NotFoundError` are problems with the
+ *request*. Retrying is pointless — the same CEP will fail again.
+ - `Cepis.NetworkError`, `Cepis.ServerError` and `Cepis.RateLimitError` are
+ problems with *transport*. Retrying may help, and the client already
+ retried on its own before returning any of them.
+
+ That split is why `Cepis.NotFoundError` is not "the HTTP 404 error": a
+ malformed CEP is rejected before it ever leaves the machine, with no request
+ made at all.
+ """
+
+ @exceptions [
+ Cepis.InvalidCEPError,
+ Cepis.NotFoundError,
+ Cepis.NetworkError,
+ Cepis.ServerError,
+ Cepis.RateLimitError,
+ Cepis.InvalidResponseError
+ ]
+
+ @type t ::
+ Cepis.InvalidCEPError.t()
+ | Cepis.NotFoundError.t()
+ | Cepis.NetworkError.t()
+ | Cepis.ServerError.t()
+ | Cepis.RateLimitError.t()
+ | Cepis.InvalidResponseError.t()
+
+ @doc """
+ Every exception module this library produces.
+
+ Useful for building a `rescue` clause around the bang variants:
+
+ rescue
+ error in [Cepis.NotFoundError, Cepis.InvalidCEPError] -> ...
+ end
+ """
+ @spec exceptions() :: [module()]
+ def exceptions, do: @exceptions
+
+ @doc """
+ Whether `term` is one of this library's errors.
+ """
+ @spec error?(term()) :: boolean()
+ def error?(%module{}), do: module in @exceptions
+ def error?(_), do: false
+
+ @doc """
+ Whether retrying the same call later could plausibly succeed.
+
+ `true` for transport failures (network, 5xx, 429), `false` for definitive
+ answers about the request itself.
+ """
+ @spec retriable?(t()) :: boolean()
+ def retriable?(%Cepis.NetworkError{}), do: true
+ def retriable?(%Cepis.ServerError{}), do: true
+ def retriable?(%Cepis.RateLimitError{}), do: true
+ def retriable?(_), do: false
+end
diff --git a/lib/cepis/geo.ex b/lib/cepis/geo.ex
new file mode 100644
index 0000000..047d87c
--- /dev/null
+++ b/lib/cepis/geo.ex
@@ -0,0 +1,85 @@
+defmodule Cepis.Geo do
+ @moduledoc """
+ The CEP's coordinate, when the database has one.
+
+ The point is the **median** of the coordinates of that CEP's addresses, not
+ the mean: one badly geocoded address 40 km away shifts a mean and does not
+ move a median.
+
+ Always check `precisao` before using `lat`/`lon` for distance:
+
+ - `"logradouro"`: the point is a stretch of street. `raio_m` lands around
+ 100–150 m. Good for maps and for distance.
+ - `"localidade"`: the CEP *is* the whole municipality (a city-wide general
+ CEP), and the point is its centroid. `raio_m` runs from 7 km to 40 km.
+ Computing shipping from this is wrong by tens of kilometres.
+
+ `raio_m` is the radius containing 90% of that CEP's addresses. It exists
+ precisely so that error cannot pass unnoticed.
+ """
+
+ @enforce_keys [:lat, :lon, :raio_m, :precisao]
+ defstruct [:lat, :lon, :raio_m, :precisao]
+
+ @type t :: %__MODULE__{
+ lat: float(),
+ lon: float(),
+ raio_m: integer(),
+ precisao: String.t()
+ }
+
+ @doc """
+ `true` when the coordinate points at a street, not a municipality.
+
+ Shorthand for `precisao == "logradouro"`, which is the check to make before
+ using the point to measure distance.
+
+ if geo && Cepis.Geo.precise?(geo) do
+ shipping_from(geo.lat, geo.lon)
+ else
+ shipping_from_municipality(address.ibge)
+ end
+ """
+ @spec precise?(t()) :: boolean()
+ def precise?(%__MODULE__{precisao: precisao}), do: precisao == "logradouro"
+
+ @doc false
+ @spec from_map(term()) :: t() | nil
+ def from_map(%{"lat" => lat, "lon" => lon, "raio_m" => raio_m, "precisao" => precisao})
+ when is_binary(precisao) do
+ %__MODULE__{
+ lat: to_float(lat),
+ lon: to_float(lon),
+ raio_m: to_integer(raio_m),
+ precisao: precisao
+ }
+ rescue
+ ArgumentError -> nil
+ end
+
+ def from_map(_), do: nil
+
+ defp to_float(value) when is_float(value), do: value
+ defp to_float(value) when is_integer(value), do: value * 1.0
+
+ defp to_float(value) when is_binary(value) do
+ case Float.parse(value) do
+ {number, ""} -> number
+ _ -> raise ArgumentError, "not a number"
+ end
+ end
+
+ defp to_float(_), do: raise(ArgumentError, "not a number")
+
+ defp to_integer(value) when is_integer(value), do: value
+ defp to_integer(value) when is_float(value), do: trunc(value)
+
+ defp to_integer(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {number, ""} -> number
+ _ -> raise ArgumentError, "not an integer"
+ end
+ end
+
+ defp to_integer(_), do: raise(ArgumentError, "not an integer")
+end
diff --git a/lib/cepis/health.ex b/lib/cepis/health.ex
new file mode 100644
index 0000000..9a25c83
--- /dev/null
+++ b/lib/cepis/health.ex
@@ -0,0 +1,64 @@
+defmodule Cepis.Health do
+ @moduledoc """
+ The response of `GET /health`.
+
+ Good for two things: checking that the API answers, and knowing **which
+ version of the database** is live. The DNE database changes every two weeks;
+ `dne` identifies it.
+ """
+
+ defstruct status: "", dne: "", ceps: 0, geo: 0, base_gerada_em: ""
+
+ @type t :: %__MODULE__{
+ status: String.t(),
+ dne: String.t(),
+ ceps: non_neg_integer(),
+ geo: non_neg_integer(),
+ base_gerada_em: String.t()
+ }
+
+ @doc """
+ `true` when the API reports itself healthy.
+ """
+ @spec ok?(t()) :: boolean()
+ def ok?(%__MODULE__{status: status}), do: status == "ok"
+
+ @doc false
+ @spec from_map(map()) :: t()
+ def from_map(data) when is_map(data) do
+ %__MODULE__{
+ status: text(data, "status"),
+ dne: text(data, "dne"),
+ ceps: count(data, "ceps"),
+ geo: count(data, "geo"),
+ base_gerada_em: text(data, "base_gerada_em")
+ }
+ end
+
+ 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/lib/cepis/http.ex b/lib/cepis/http.ex
new file mode 100644
index 0000000..0985c93
--- /dev/null
+++ b/lib/cepis/http.ex
@@ -0,0 +1,197 @@
+defmodule Cepis.HTTP do
+ @moduledoc false
+
+ alias Cepis.Client
+ alias Cepis.{NetworkError, RateLimitError, ServerError}
+
+ @retriable_status [429, 500, 502, 503, 504]
+ @accept ~c"application/json, text/csv;q=0.9, */*;q=0.1"
+ @epoch_offset 62_167_219_200
+
+ @spec request(Client.t(), String.t(), [pos_integer()]) ::
+ {:ok, pos_integer(), binary(), list()} | {:error, Cepis.Error.t()}
+ def request(%Client{} = client, path, accepted \\ [200]) do
+ url = client.base_url <> path
+
+ deadline =
+ if client.total_timeout, do: monotonic() + client.total_timeout
+
+ attempt(client, url, accepted, 1, deadline, nil)
+ end
+
+ defp attempt(client, url, accepted, number, deadline, last) do
+ if expired?(deadline) do
+ give_up(client, url, last)
+ else
+ case get(client, url) do
+ {:ok, status, body, headers} ->
+ if status in accepted or status not in @retriable_status do
+ {:ok, status, body, headers}
+ else
+ next(client, url, accepted, number, deadline, {:status, status, retry_after(headers)})
+ end
+
+ {:error, reason} ->
+ next(client, url, accepted, number, deadline, {:network, reason})
+ end
+ end
+ end
+
+ defp next(client, url, accepted, number, deadline, last) do
+ if number >= client.max_attempts do
+ give_up(client, url, last)
+ else
+ case wait_for(client, number, deadline, last) do
+ :expired ->
+ give_up(client, url, last)
+
+ milliseconds ->
+ Process.sleep(milliseconds)
+ attempt(client, url, accepted, number + 1, deadline, last)
+ end
+ end
+ end
+
+ defp wait_for(client, number, deadline, last) do
+ wait = backoff(client, number, requested_wait(last))
+
+ case deadline do
+ nil ->
+ wait
+
+ _ ->
+ remaining = deadline - monotonic()
+ if remaining <= 0, do: :expired, else: min(wait, remaining)
+ end
+ end
+
+ defp requested_wait({:status, _status, retry_after}), do: retry_after
+ defp requested_wait(_), do: nil
+
+ defp backoff(client, _number, retry_after) when is_integer(retry_after) do
+ min(retry_after, client.backoff_max)
+ end
+
+ defp backoff(client, number, _retry_after) do
+ ceiling = min(client.backoff_base * Integer.pow(2, min(number - 1, 30)), client.backoff_max)
+ trunc(:rand.uniform() * ceiling)
+ end
+
+ defp get(client, url) do
+ ensure_started()
+
+ request = {String.to_charlist(url), headers(client)}
+
+ http_options =
+ [
+ timeout: client.timeout,
+ connect_timeout: client.timeout
+ ] ++ tls_options(url)
+
+ case :httpc.request(:get, request, http_options, body_format: :binary) do
+ {:ok, {{_version, status, _reason}, headers, body}} -> {:ok, status, body, headers}
+ {:ok, {status, body}} -> {:ok, status, body, []}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ defp headers(client) do
+ [
+ {~c"user-agent", String.to_charlist(client.user_agent)},
+ {~c"accept", @accept},
+ {~c"accept-encoding", ~c"identity"}
+ ]
+ end
+
+ defp tls_options("https://" <> _rest) do
+ [
+ ssl: [
+ verify: :verify_peer,
+ cacerts: :public_key.cacerts_get(),
+ depth: 3,
+ customize_hostname_check: [
+ match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
+ ]
+ ]
+ ]
+ rescue
+ _ -> []
+ end
+
+ defp tls_options(_url), do: []
+
+ defp ensure_started do
+ {:ok, _} = Application.ensure_all_started(:inets)
+ {:ok, _} = Application.ensure_all_started(:ssl)
+ :ok
+ end
+
+ defp retry_after(headers) do
+ case header(headers, "retry-after") do
+ nil -> nil
+ value -> parse_retry_after(String.trim(value))
+ end
+ end
+
+ defp parse_retry_after(value) do
+ case Float.parse(value) do
+ {seconds, ""} -> max(0, trunc(seconds * 1000))
+ _ -> parse_http_date(value)
+ end
+ end
+
+ defp parse_http_date(value) do
+ case :httpd_util.convert_request_date(String.to_charlist(value)) do
+ :bad_date ->
+ nil
+
+ datetime ->
+ target = :calendar.datetime_to_gregorian_seconds(datetime) - @epoch_offset
+ max(0, (target - System.os_time(:second)) * 1000)
+ end
+ end
+
+ defp header(headers, name) do
+ Enum.find_value(headers, fn {key, value} ->
+ if key |> to_string() |> String.downcase() == name, do: to_string(value)
+ end)
+ end
+
+ defp give_up(client, url, {:status, 429, retry_after}) do
+ {:error,
+ %RateLimitError{
+ message: "rate limit reached at #{url} (after #{client.max_attempts} attempt(s))",
+ retry_after: retry_after
+ }}
+ end
+
+ defp give_up(client, url, {:status, status, _retry_after}) do
+ {:error,
+ %ServerError{
+ message: "the API answered #{status} at #{url} (after #{client.max_attempts} attempt(s))",
+ status: status
+ }}
+ end
+
+ defp give_up(client, url, {:network, reason}) do
+ {:error,
+ %NetworkError{
+ message:
+ "could not reach #{url} (after #{client.max_attempts} attempt(s)): #{inspect(reason)}",
+ reason: reason
+ }}
+ end
+
+ defp give_up(_client, url, nil) do
+ {:error,
+ %NetworkError{
+ message: "gave up on #{url}: total_timeout elapsed before any attempt completed",
+ reason: :timeout
+ }}
+ end
+
+ defp expired?(nil), do: false
+ defp expired?(deadline), do: monotonic() >= deadline
+
+ defp monotonic, do: System.monotonic_time(:millisecond)
+end