diff options
Diffstat (limited to 'lib/cepis.ex')
| -rw-r--r-- | lib/cepis.ex | 277 |
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 |