From 075683ff44a254e99adbad8a0372ef80ff06151a Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 12:19:34 -0300 Subject: Initial import --- lib/cepis/address.ex | 101 ++++++++++++++++++++++++++ lib/cepis/client.ex | 114 +++++++++++++++++++++++++++++ lib/cepis/csv.ex | 65 +++++++++++++++++ lib/cepis/error.ex | 157 ++++++++++++++++++++++++++++++++++++++++ lib/cepis/geo.ex | 85 ++++++++++++++++++++++ lib/cepis/health.ex | 64 +++++++++++++++++ lib/cepis/http.ex | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 783 insertions(+) create mode 100644 lib/cepis/address.ex create mode 100644 lib/cepis/client.ex create mode 100644 lib/cepis/csv.ex create mode 100644 lib/cepis/error.ex create mode 100644 lib/cepis/geo.ex create mode 100644 lib/cepis/health.ex create mode 100644 lib/cepis/http.ex (limited to 'lib/cepis') 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 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: <>}) 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(<>, [], row, rows), do: inside(rest, [], row, rows) + + defp outside(<>, field, row, rows), + do: outside(rest, [], [flush(field) | row], rows) + + defp outside(<>, field, row, rows), + do: outside(rest, [], [], [Enum.reverse([flush(field) | row]) | rows]) + + defp outside(<>, field, row, rows), + do: outside(rest, [], [], [Enum.reverse([flush(field) | row]) | rows]) + + defp outside(<>, field, row, rows), + do: outside(rest, [field, char], row, rows) + + defp inside(<<>>, field, row, rows), do: emit(field, row, rows) + + defp inside(<>, field, row, rows), + do: inside(rest, [field, ?"], row, rows) + + defp inside(<>, field, row, rows), do: outside(rest, field, row, rows) + + defp inside(<>, 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 -- cgit v1.3