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