summaryrefslogtreecommitdiffstats
path: root/lib/cepis.ex
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/cepis.ex
downloadelixir-075683ff44a254e99adbad8a0372ef80ff06151a.tar.gz
Initial importv0.1.0
Diffstat (limited to 'lib/cepis.ex')
-rw-r--r--lib/cepis.ex277
1 files changed, 277 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