summaryrefslogtreecommitdiffstats
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
downloadelixir-0785ed6b367d134b192c89fbf840532f172a3b4a.tar.gz
Initial importv0.1.0
-rw-r--r--.formatter.exs3
-rw-r--r--.gitignore9
-rw-r--r--LICENSE15
-rw-r--r--Makefile41
-rw-r--r--README.md203
-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
-rw-r--r--mix.exs38
-rw-r--r--test/cepis/batch_test.exs98
-rw-r--r--test/cepis/csv_test.exs49
-rw-r--r--test/cepis/health_test.exs45
-rw-r--r--test/cepis/lookup_test.exs152
-rw-r--r--test/cepis/retry_test.exs90
-rw-r--r--test/cepis_test.exs79
-rw-r--r--test/support/fake_server.ex154
-rw-r--r--test/support/server_case.ex52
-rw-r--r--test/test_helper.exs1
23 files changed, 2089 insertions, 0 deletions
diff --git a/.formatter.exs b/.formatter.exs
new file mode 100644
index 0000000..682fc6c
--- /dev/null
+++ b/.formatter.exs
@@ -0,0 +1,3 @@
+[
+ inputs: ["{mix,.formatter}.exs", "{lib,test}/**/*.{ex,exs}"]
+]
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..be72424
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+/_build/
+/cover/
+/deps/
+/doc/
+/.fetch
+erl_crash.dump
+*.ez
+cepis-*.tar
+.elixir_ls/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..e5501e6
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,41 @@
+# Development tasks. Everything runs through `mix`, so there is nothing to
+# install first: the library has no dependencies.
+#
+# make list the targets
+# make test run the ExUnit suite
+# make lint mix format --check-formatted, no files touched
+# make build compile with warnings as errors
+MIX ?= mix
+
+.DEFAULT_GOAL := help
+.PHONY: help test lint format build check clean
+
+help:
+ @echo 'Targets:'
+ @echo ' test run the ExUnit suite'
+ @echo ' lint mix format --check-formatted (read-only)'
+ @echo ' format apply mix format (writes)'
+ @echo ' build compile with warnings as errors'
+ @echo ' check lint + test + build, in that order'
+ @echo ' clean remove build artefacts'
+
+test:
+ $(MIX) test
+
+# Read-only by design, so it is safe in CI and in a pre-commit hook: it reports
+# what `make format` would change without changing anything.
+lint:
+ $(MIX) format --check-formatted
+
+format:
+ $(MIX) format
+
+build:
+ $(MIX) compile --warnings-as-errors
+
+# The order matters: lint is the fastest to fail, build the slowest, so a
+# broken run reports the cheapest problem first.
+check: lint test build
+
+clean:
+ rm -rf _build deps doc cover
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bb79b2a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,203 @@
+# cepis
+
+Elixir client for the [public CEP API at cep.is](https://cep.is). Official
+Correios data (DNE), refreshed every two weeks.
+
+```elixir
+{:ok, address} = Cepis.lookup("01001-000")
+{address.logradouro, address.localidade, address.uf}
+# {"Praça da Sé", "São Paulo", "SP"}
+```
+
+## Installation
+
+```elixir
+def deps do
+ [{:cepis, git: "https://git.cep.is/elixir.git", tag: "v0.1.0"}]
+end
+```
+
+No dependencies. Requires Elixir 1.14 or newer and Erlang/OTP 27 or newer —
+HTTP is `:httpc` and JSON is the `:json` module, both from the standard
+distribution.
+
+## Usage
+
+Every function returns `{:ok, result}` or `{:error, error}`, and every one has
+a bang variant that returns the result directly and raises instead.
+
+### A single lookup
+
+`lookup/2` accepts the CEP masked or unmasked and returns a `Cepis.Address`:
+
+```elixir
+{:ok, a} = Cepis.lookup("01310-100")
+
+a.logradouro # "Avenida Paulista"
+a.localidade # "São Paulo"
+a.uf # "SP"
+a.ibge # "3550308"
+a.cep # "01310100" (always unmasked)
+Cepis.Address.formatted_cep(a) # "01310-100"
+```
+
+Field names are the API's own, in Portuguese. Fields the database does not have
+for that CEP come back as empty strings, never `nil`.
+
+### Coordinates
+
+About 57% of CEPs have a coordinate. When they do not, `geo` is `nil`:
+
+```elixir
+{:ok, a} = Cepis.lookup("01310100")
+
+if a.geo do
+ a.geo.lat # -23.567202
+ a.geo.lon # -46.649659
+ a.geo.raio_m # 1084
+ a.geo.precisao # "logradouro"
+end
+```
+
+**Check `precisao` before computing distance.** There are two kinds of point,
+and the difference between them is tens of kilometres:
+
+| `precisao` | typical radius | what the point is |
+| --- | --- | --- |
+| `logradouro` | 110–140 m | the stretch of street — good for maps and distance |
+| `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city |
+
+`Cepis.Geo.precise?/1` is the shorthand for `precisao == "logradouro"`:
+
+```elixir
+if a.geo && Cepis.Geo.precise?(a.geo) do
+ shipping_from(a.geo.lat, a.geo.lon)
+else
+ shipping_from_municipality(a.ibge)
+end
+```
+
+### Many CEPs at once
+
+Do not loop over `lookup/2`. Use `lookup_many/2`, which resolves up to 500 CEPs
+per request:
+
+```elixir
+ceps = ["01001000", "99999999", "20040020"]
+{:ok, addresses} = Cepis.lookup_many(ceps)
+
+for {requested, address} <- Enum.zip(ceps, addresses) do
+ case address do
+ nil -> IO.puts("#{requested}: not found")
+ a -> IO.puts("#{requested}: #{a.localidade}")
+ end
+end
+```
+
+The returned list has **the same length and the same order** as the input. A
+CEP that does not exist, or is malformed, becomes `nil` at its position — it
+does not vanish, and it does not fail the call.
+
+Lists longer than 500 are chunked automatically.
+
+> **Batches carry no coordinate.** The endpoint behind them is CSV, and the
+> CSV has no `geo` column. Every `Cepis.Address` from `lookup_many/2` has
+> `geo: nil`, including for CEPs that do have one. Need `geo`? Use `lookup/2`,
+> one at a time.
+
+### API health
+
+Useful for two things: checking the API answers, and knowing **which version of
+the database** is live:
+
+```elixir
+{:ok, h} = Cepis.health()
+
+Cepis.Health.ok?(h) # true
+h.dne # "26071" identifier of the fortnightly database
+h.ceps # 1596168
+h.base_gerada_em # "2026-07-24T03:48:28Z"
+```
+
+An API reporting itself degraded is not a failure — that arrives in `h.status`.
+
+## Configuration
+
+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. It is plain
+data with no process behind it, so it is safe to share across processes.
+
+```elixir
+client =
+ Cepis.Client.new(
+ base_url: "https://cep.is", # point at another instance
+ timeout: 10_000, # milliseconds for one attempt
+ max_attempts: 3, # counting the first; 1 disables retrying
+ backoff_base: 500, # base of the exponential wait
+ backoff_max: 8_000, # cap on each wait
+ total_timeout: nil # cap on the whole call, attempts included
+ )
+
+{:ok, address} = Cepis.lookup("01001000", client)
+```
+
+All durations are in milliseconds. Retries cover 429, 5xx and network failures,
+with exponential backoff and jitter; 400 and 404 are never retried. See
+`Cepis.Client` for the details of each option.
+
+### Pointing at a local instance
+
+`base_url` is the only option that usually changes between environments. The
+API repo brings a local instance up with `just up`:
+
+```elixir
+Cepis.lookup("01001000", base_url: "http://localhost:8080")
+```
+
+## Errors
+
+Matching on `{:error, error}` is the one handler that catches everything. Below
+that, the split is by **the decision you have to make**, not by the HTTP status
+that caused it:
+
+| error | when | does retrying help? |
+| --- | --- | --- |
+| `Cepis.InvalidCEPError` | not 8 digits | no |
+| `Cepis.NotFoundError` | well-formed, not in the database | no |
+| `Cepis.RateLimitError` | 429 | yes, later |
+| `Cepis.ServerError` | 5xx | yes, later |
+| `Cepis.NetworkError` | DNS, connection, timeout, TLS | yes, later |
+| `Cepis.InvalidResponseError` | unintelligible response | probably not |
+
+```elixir
+case Cepis.lookup(cep_from_form) do
+ {:ok, address} -> address
+ {:error, %Cepis.NotFoundError{}} -> ask_for_another_cep()
+ {:error, error} -> reschedule(error)
+end
+```
+
+`Cepis.Error.retriable?/1` answers the same question in code.
+`Cepis.RateLimitError` exposes `retry_after` in milliseconds, ready for
+`Process.send_after/3`.
+
+`Cepis.InvalidResponseError` almost always 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.
+
+## Development
+
+Everything runs through `make`, which shells out to `mix`:
+
+```sh
+make # list the targets
+make test # run the ExUnit suite
+make lint # mix format --check-formatted, touches nothing
+make format # apply mix format
+make build # compile with warnings as errors
+make check # lint, then test, then build
+```
+
+## Licence
+
+ISC. See [LICENSE](LICENSE).
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
diff --git a/mix.exs b/mix.exs
new file mode 100644
index 0000000..8a1625c
--- /dev/null
+++ b/mix.exs
@@ -0,0 +1,38 @@
+defmodule Cepis.MixProject do
+ use Mix.Project
+
+ @version "0.1.0"
+ @source_url "https://git.cep.is/elixir.git"
+
+ def project do
+ [
+ app: :cepis,
+ version: @version,
+ elixir: "~> 1.14",
+ elixirc_paths: elixirc_paths(Mix.env()),
+ start_permanent: Mix.env() == :prod,
+ deps: deps(),
+ name: "cepis",
+ description: "Client for the public CEP API at cep.is",
+ package: package(),
+ source_url: @source_url
+ ]
+ end
+
+ def application do
+ [extra_applications: [:inets, :ssl, :public_key]]
+ end
+
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
+ defp elixirc_paths(_), do: ["lib"]
+
+ defp deps, do: []
+
+ defp package do
+ [
+ licenses: ["ISC"],
+ links: %{"Source" => @source_url, "API" => "https://cep.is"},
+ files: ~w(lib mix.exs README.md LICENSE Makefile .formatter.exs)
+ ]
+ end
+end
diff --git a/test/cepis/batch_test.exs b/test/cepis/batch_test.exs
new file mode 100644
index 0000000..dd8fe83
--- /dev/null
+++ b/test/cepis/batch_test.exs
@@ -0,0 +1,98 @@
+defmodule Cepis.BatchTest do
+ use Cepis.ServerCase
+
+ test "order is preserved and a missing CEP becomes nil", %{server: server, client: client} do
+ body =
+ csv([
+ "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo," <>
+ "Sudeste,3550308,1004,11,7107",
+ "99999999,,,,,,,,,,,,",
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ," <>
+ "Rio de Janeiro,Sudeste,3304557,,21,6001"
+ ])
+
+ FakeServer.on_request(server, fn _path, _n -> {200, body, []} end)
+
+ assert {:ok, [first, nil, third]} =
+ Cepis.lookup_many(["01001000", "99999999", "20040020"], client)
+
+ assert first.localidade == "São Paulo"
+ assert third.uf == "RJ"
+
+ assert [path] = FakeServer.calls(server)
+ assert path == "/csv?ceps=01001000%2C99999999%2C20040020"
+ end
+
+ test "batches never carry a coordinate", %{server: server, client: client} do
+ body = csv(["01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107"])
+ FakeServer.on_request(server, fn _path, _n -> {200, body, []} end)
+
+ assert {:ok, [address]} = Cepis.lookup_many(["01001000"], client)
+ assert address.geo == nil
+ end
+
+ test "a quoted field containing a comma is parsed, not split", %{server: server, client: client} do
+ body = csv([~s(01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1)])
+ FakeServer.on_request(server, fn _path, _n -> {200, body, []} end)
+
+ assert {:ok, [address]} = Cepis.lookup_many(["01001000"], client)
+ assert address.logradouro == "Rua Um, Dois"
+ end
+
+ test "an escaped quote inside a quoted field is unescaped", %{server: server, client: client} do
+ body = csv([~s(01001000,"Rua ""A""",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1)])
+ FakeServer.on_request(server, fn _path, _n -> {200, body, []} end)
+
+ assert {:ok, [address]} = Cepis.lookup_many(["01001000"], client)
+ assert address.logradouro == ~s(Rua "A")
+ end
+
+ test "an empty list makes no request", %{server: server, client: client} do
+ assert Cepis.lookup_many([], client) == {:ok, []}
+ assert FakeServer.calls(server) == []
+ end
+
+ test "more than 500 CEPs become more than one request", %{server: server, client: client} do
+ ceps = Enum.map(0..500, &String.pad_leading(to_string(&1), 8, "0"))
+ {first, second} = Enum.split(ceps, 500)
+
+ rows = fn chunk ->
+ csv(Enum.map(chunk, &"#{&1},,,,,X,SP,São Paulo,Sudeste,1,,11,1"))
+ end
+
+ bodies = %{1 => rows.(first), 2 => rows.(second)}
+ FakeServer.on_request(server, fn _path, n -> {200, Map.fetch!(bodies, n), []} end)
+
+ assert {:ok, result} = Cepis.lookup_many(ceps, client)
+ assert length(result) == 501
+ assert Enum.all?(result, &(&1.localidade == "X"))
+ assert length(FakeServer.calls(server)) == 2
+ end
+
+ test "a truncated response pads rather than shifting", %{server: server, client: client} do
+ body = csv(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"])
+ FakeServer.on_request(server, fn _path, _n -> {200, body, []} end)
+
+ assert {:ok, [first, nil]} = Cepis.lookup_many(["01001000", "20040020"], client)
+ assert first.localidade == "São Paulo"
+ end
+
+ test "an empty body yields a full list of nils", %{server: server, client: client} do
+ FakeServer.on_request(server, fn _path, _n -> {200, "", []} end)
+
+ assert {:ok, [nil, nil]} = Cepis.lookup_many(["01001000", "20040020"], client)
+ end
+
+ test "a transport failure fails the whole batch", %{server: server, client: client} do
+ FakeServer.on_request(server, fn _path, _n -> {503, "", []} end)
+
+ assert {:error, %Cepis.ServerError{status: 503}} = Cepis.lookup_many(["01001000"], client)
+ end
+
+ test "the bang variant returns the list", %{server: server, client: client} do
+ body = csv(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"])
+ FakeServer.on_request(server, fn _path, _n -> {200, body, []} end)
+
+ assert [%Cepis.Address{}] = Cepis.lookup_many!(["01001000"], client)
+ end
+end
diff --git a/test/cepis/csv_test.exs b/test/cepis/csv_test.exs
new file mode 100644
index 0000000..719de66
--- /dev/null
+++ b/test/cepis/csv_test.exs
@@ -0,0 +1,49 @@
+defmodule Cepis.CSVTest do
+ use ExUnit.Case, async: true
+
+ alias Cepis.CSV
+
+ test "parses plain rows" do
+ assert CSV.parse("a,b\n1,2\n") == [["a", "b"], ["1", "2"]]
+ end
+
+ test "tolerates a missing final newline" do
+ assert CSV.parse("a,b\n1,2") == [["a", "b"], ["1", "2"]]
+ end
+
+ test "tolerates CRLF" do
+ assert CSV.parse("a,b\r\n1,2\r\n") == [["a", "b"], ["1", "2"]]
+ end
+
+ test "keeps empty fields" do
+ assert CSV.parse("a,b,c\n1,,3\n") == [["a", "b", "c"], ["1", "", "3"]]
+ end
+
+ test "unquotes fields containing commas, quotes and newlines" do
+ text = ~s(a,b\n"x, y","he said ""hi"""\n)
+ assert CSV.parse(text) == [["a", "b"], ["x, y", ~s(he said "hi")]]
+
+ assert CSV.parse(~s(a\n"one\ntwo"\n)) == [["a"], ["one\ntwo"]]
+ end
+
+ test "strips a byte order mark" do
+ assert CSV.parse("a,b\n1,2\n") == [["a", "b"], ["1", "2"]]
+ end
+
+ test "keeps multi-byte characters intact" do
+ assert CSV.parse("a\nSão Paulo\n") == [["a"], ["São Paulo"]]
+ end
+
+ test "an empty document has no rows" do
+ assert CSV.parse("") == []
+ assert CSV.parse("\n") == []
+ end
+
+ test "pairs values with the header, padding short rows" do
+ assert CSV.parse_with_header("a,b,c\n1,2\n") == [%{"a" => "1", "b" => "2", "c" => ""}]
+ end
+
+ test "a header alone yields no rows" do
+ assert CSV.parse_with_header("a,b,c\n") == []
+ end
+end
diff --git a/test/cepis/health_test.exs b/test/cepis/health_test.exs
new file mode 100644
index 0000000..47a49c2
--- /dev/null
+++ b/test/cepis/health_test.exs
@@ -0,0 +1,45 @@
+defmodule Cepis.HealthTest do
+ use Cepis.ServerCase
+
+ test "reads the live database identifier", %{server: server, client: client} do
+ body =
+ json(%{
+ "status" => "ok",
+ "dne" => "26071",
+ "ceps" => 1_596_168,
+ "geo" => 894_489,
+ "base_gerada_em" => "2026-07-24T03:48:28Z"
+ })
+
+ FakeServer.respond(server, "/health", {200, body, []})
+
+ assert {:ok, health} = Cepis.health(client)
+ assert Cepis.Health.ok?(health)
+ assert health.dne == "26071"
+ assert health.ceps == 1_596_168
+ assert health.geo == 894_489
+ assert health.base_gerada_em == "2026-07-24T03:48:28Z"
+ end
+
+ test "a degraded API is a diagnosis, not a failure", %{server: server, client: client} do
+ body = json(%{"status" => "degradado", "dne" => "", "ceps" => 0, "geo" => 0})
+ FakeServer.respond(server, "/health", {503, body, []})
+
+ assert {:ok, health} = Cepis.health(client)
+ refute Cepis.Health.ok?(health)
+ assert health.status == "degradado"
+ end
+
+ test "a 503 on /health is not retried", %{server: server, client: client} do
+ FakeServer.respond(server, "/health", {503, json(%{"status" => "degradado"}), []})
+
+ assert {:ok, _health} = Cepis.health(client)
+ assert FakeServer.count(server, "/health") == 1
+ end
+
+ test "the bang variant returns the struct", %{server: server, client: client} do
+ FakeServer.respond(server, "/health", {200, json(%{"status" => "ok"}), []})
+
+ assert %Cepis.Health{status: "ok"} = Cepis.health!(client)
+ end
+end
diff --git a/test/cepis/lookup_test.exs b/test/cepis/lookup_test.exs
new file mode 100644
index 0000000..b68ecbe
--- /dev/null
+++ b/test/cepis/lookup_test.exs
@@ -0,0 +1,152 @@
+defmodule Cepis.LookupTest do
+ use Cepis.ServerCase
+
+ test "resolves a CEP", %{server: server, client: client} do
+ FakeServer.respond(server, "/01001000", {200, json(address_fixture()), []})
+
+ assert {:ok, address} = Cepis.lookup("01001-000", client)
+ assert address.logradouro == "Praça da Sé"
+ assert address.localidade == "São Paulo"
+ assert address.uf == "SP"
+ assert Cepis.Address.formatted_cep(address) == "01001-000"
+ assert address.geo == nil
+ end
+
+ test "reads the coordinate when the API sends one", %{server: server, client: client} do
+ body =
+ address_fixture(%{
+ "geo" => %{"lat" => -23.5, "lon" => -46.6, "raio_m" => 120, "precisao" => "logradouro"}
+ })
+
+ FakeServer.respond(server, "/01310100", {200, json(body), []})
+
+ assert {:ok, address} = Cepis.lookup("01310100", client)
+ assert address.geo.lat == -23.5
+ assert address.geo.lon == -46.6
+ assert address.geo.raio_m == 120
+ assert Cepis.Geo.precise?(address.geo)
+ end
+
+ test "a municipality centroid is not precise", %{server: server, client: client} do
+ body =
+ address_fixture(%{
+ "geo" => %{"lat" => -23.5, "lon" => -46.6, "raio_m" => 22_000, "precisao" => "localidade"}
+ })
+
+ FakeServer.respond(server, "/01001000", {200, json(body), []})
+
+ assert {:ok, address} = Cepis.lookup("01001000", client)
+ refute Cepis.Geo.precise?(address.geo)
+ end
+
+ test "a malformed geo block does not lose the address", %{server: server, client: client} do
+ body =
+ address_fixture(%{
+ "geo" => %{
+ "lat" => "not a number",
+ "lon" => "-46.6",
+ "raio_m" => "120",
+ "precisao" => "logradouro"
+ }
+ })
+
+ FakeServer.respond(server, "/01001000", {200, json(body), []})
+
+ assert {:ok, address} = Cepis.lookup("01001000", client)
+ assert address.geo == nil
+ assert address.localidade == "São Paulo"
+ end
+
+ test "a truncated geo block does not lose the address", %{server: server, client: client} do
+ FakeServer.respond(server, "/01001000", {200, json(address_fixture(%{"geo" => %{}})), []})
+
+ assert {:ok, address} = Cepis.lookup("01001000", client)
+ assert address.geo == nil
+ assert address.uf == "SP"
+ end
+
+ test "a field the API grows is kept in raw", %{server: server, client: client} do
+ body = address_fixture(%{"campo_do_futuro" => "value"})
+ FakeServer.respond(server, "/01001000", {200, json(body), []})
+
+ assert {:ok, address} = Cepis.lookup("01001000", client)
+ assert address.raw["campo_do_futuro"] == "value"
+ assert address.localidade == "São Paulo"
+ end
+
+ test "a missing CEP is a not-found error", %{server: server, client: client} do
+ FakeServer.respond(
+ server,
+ "/99999999",
+ {404, json(%{"erro" => "cep_nao_encontrado", "mensagem" => "gone"}), []}
+ )
+
+ assert {:error, %Cepis.NotFoundError{cep: "99999999"} = error} =
+ Cepis.lookup("99999999", client)
+
+ assert Exception.message(error) =~ "99999999"
+ end
+
+ test "a 404 is never retried", %{server: server, client: client} do
+ FakeServer.respond(server, "/99999999", {404, json(%{"erro" => "cep_nao_encontrado"}), []})
+
+ assert {:error, %Cepis.NotFoundError{}} = Cepis.lookup("99999999", client)
+ assert FakeServer.count(server, "/99999999") == 1
+ end
+
+ test "a malformed CEP never leaves the machine", %{server: server, client: client} do
+ assert {:error, %Cepis.InvalidCEPError{}} = Cepis.lookup("123", client)
+ assert FakeServer.calls(server) == []
+ end
+
+ test "a 400 carries the API's own message", %{server: server, client: client} do
+ FakeServer.respond(
+ server,
+ "/00000000",
+ {400, json(%{"erro" => "cep_invalido", "mensagem" => "rejected by the API"}), []}
+ )
+
+ assert {:error, %Cepis.InvalidCEPError{} = error} = Cepis.lookup("00000000", client)
+ assert Exception.message(error) =~ "rejected by the API"
+ end
+
+ test "a 400 is never retried", %{server: server, client: client} do
+ FakeServer.respond(server, "/00000000", {400, json(%{"erro" => "cep_invalido"}), []})
+
+ assert {:error, %Cepis.InvalidCEPError{}} = Cepis.lookup("00000000", client)
+ assert FakeServer.count(server, "/00000000") == 1
+ end
+
+ test "a captive portal answering instead of the API is diagnosed", %{
+ server: server,
+ client: client
+ } do
+ FakeServer.respond(server, "/01001000", {200, "<html>captive portal</html>", []})
+
+ assert {:error, %Cepis.InvalidResponseError{} = error} = Cepis.lookup("01001000", client)
+ assert Exception.message(error) =~ "captive portal"
+ end
+
+ test "a JSON body that is not an object is rejected", %{server: server, client: client} do
+ FakeServer.respond(server, "/01001000", {200, "[1,2,3]", []})
+
+ assert {:error, %Cepis.InvalidResponseError{}} = Cepis.lookup("01001000", client)
+ end
+
+ test "the bang variant returns the address and raises on failure", %{
+ server: server,
+ client: client
+ } do
+ FakeServer.respond(server, "/01001000", {200, json(address_fixture()), []})
+ assert %Cepis.Address{uf: "SP"} = Cepis.lookup!("01001000", client)
+
+ assert_raise Cepis.NotFoundError, fn -> Cepis.lookup!("99999999", client) end
+ end
+
+ test "keyword options work in place of a client struct", %{server: server} do
+ FakeServer.respond(server, "/01001000", {200, json(address_fixture()), []})
+
+ assert {:ok, address} = Cepis.lookup("01001000", base_url: server.url, timeout: 5_000)
+ assert address.localidade == "São Paulo"
+ end
+end
diff --git a/test/cepis/retry_test.exs b/test/cepis/retry_test.exs
new file mode 100644
index 0000000..3308ca4
--- /dev/null
+++ b/test/cepis/retry_test.exs
@@ -0,0 +1,90 @@
+defmodule Cepis.RetryTest do
+ use Cepis.ServerCase
+
+ test "a 500 is retried and the second attempt wins", %{server: server, client: client} do
+ FakeServer.respond(server, "/01001000", [
+ {500, json(%{"erro" => "interno"}), []},
+ {200, json(address_fixture()), []}
+ ])
+
+ assert {:ok, address} = Cepis.lookup("01001000", client)
+ assert address.localidade == "São Paulo"
+ assert FakeServer.count(server, "/01001000") == 2
+ end
+
+ test "attempts are exhausted and then it gives up", %{server: server, client: client} do
+ FakeServer.respond(server, "/01001000", {503, json(%{"erro" => "interno"}), []})
+
+ assert {:error, %Cepis.ServerError{status: 503}} = Cepis.lookup("01001000", client)
+ assert FakeServer.count(server, "/01001000") == 3
+ end
+
+ test "max_attempts: 1 disables retrying", %{server: server} do
+ client = Cepis.Client.new(base_url: server.url, max_attempts: 1, timeout: 5_000)
+ FakeServer.respond(server, "/01001000", {500, "{}", []})
+
+ assert {:error, %Cepis.ServerError{status: 500}} = Cepis.lookup("01001000", client)
+ assert FakeServer.count(server, "/01001000") == 1
+ end
+
+ test "a 429 exposes Retry-After in milliseconds", %{server: server, client: client} do
+ FakeServer.respond(server, "/01001000", {429, "{}", [{"retry-after", "7"}]})
+
+ assert {:error, %Cepis.RateLimitError{retry_after: 7_000}} = Cepis.lookup("01001000", client)
+ end
+
+ test "a Retry-After HTTP date is understood", %{server: server, client: client} do
+ date =
+ DateTime.utc_now()
+ |> DateTime.add(30, :second)
+ |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT")
+
+ FakeServer.respond(server, "/01001000", {429, "{}", [{"retry-after", date}]})
+
+ assert {:error, %Cepis.RateLimitError{retry_after: retry_after}} =
+ Cepis.lookup("01001000", client)
+
+ assert retry_after > 20_000
+ assert retry_after <= 30_000
+ end
+
+ test "an absurd Retry-After cannot park the caller", %{server: server, client: client} do
+ FakeServer.respond(server, "/01001000", {429, "{}", [{"retry-after", "3600"}]})
+
+ {elapsed, result} = :timer.tc(fn -> Cepis.lookup("01001000", client) end, :millisecond)
+
+ assert {:error, %Cepis.RateLimitError{}} = result
+ assert elapsed < 2_000
+ end
+
+ test "nothing listening is a network error" do
+ client =
+ Cepis.Client.new(
+ base_url: "http://127.0.0.1:1",
+ max_attempts: 2,
+ backoff_base: 1,
+ timeout: 1_000
+ )
+
+ assert {:error, %Cepis.NetworkError{}} = Cepis.lookup("01001000", client)
+ end
+
+ test "total_timeout bounds the whole call", %{server: server} do
+ client =
+ Cepis.Client.new(
+ base_url: server.url,
+ max_attempts: 50,
+ backoff_base: 50,
+ backoff_max: 50,
+ total_timeout: 300
+ )
+
+ FakeServer.respond(server, "/01001000", {500, "{}", []})
+
+ {elapsed, result} = :timer.tc(fn -> Cepis.lookup("01001000", client) end, :millisecond)
+
+ assert {:error, %Cepis.ServerError{}} = result
+ assert elapsed < 3_000
+ assert FakeServer.count(server, "/01001000") < 50
+ end
+end
diff --git a/test/cepis_test.exs b/test/cepis_test.exs
new file mode 100644
index 0000000..a6bedb1
--- /dev/null
+++ b/test/cepis_test.exs
@@ -0,0 +1,79 @@
+defmodule CepisTest do
+ use ExUnit.Case, async: true
+
+ doctest Cepis
+ doctest Cepis.Client
+
+ describe "normalize_cep/1" do
+ test "accepts the mask and the bare digits" do
+ for value <- ["01001000", "01001-000", " 01001-000 ", "01001.000"] do
+ assert Cepis.normalize_cep(value) == {:ok, "01001000"}
+ end
+ end
+
+ test "rejects anything that is not 8 digits" do
+ for value <- ["", "123", "0100100a", "010010000", "abcdefgh"] do
+ assert {:error, %Cepis.InvalidCEPError{}} = Cepis.normalize_cep(value)
+ end
+ end
+
+ test "rejects a value that is not a string" do
+ assert {:error, %Cepis.InvalidCEPError{}} = Cepis.normalize_cep(1_001_000)
+ end
+
+ test "the bang variant raises" do
+ assert_raise Cepis.InvalidCEPError, fn -> Cepis.normalize_cep!("123") end
+ end
+ end
+
+ describe "Cepis.Client.new/1" do
+ test "strips a trailing slash so separators cannot double up" do
+ assert Cepis.Client.new(base_url: "http://x/").base_url == "http://x"
+ assert Cepis.Client.new(base_url: "http://x//").base_url == "http://x"
+ end
+
+ test "rejects an impossible configuration" do
+ assert_raise ArgumentError, fn -> Cepis.Client.new(max_attempts: 0) end
+ assert_raise ArgumentError, fn -> Cepis.Client.new(timeout: 0) end
+ end
+
+ test "rejects an unknown option" do
+ assert_raise KeyError, fn -> Cepis.Client.new(retries: 3) end
+ end
+
+ test "the default user agent identifies the library" do
+ assert Cepis.Client.new([]).user_agent =~ "cepis-elixir/"
+ end
+
+ test "an already built client passes through untouched" do
+ client = Cepis.Client.new(base_url: "http://x")
+ assert Cepis.Client.new(client) == client
+ end
+ end
+
+ describe "Cepis.Error" do
+ test "splits retriable transport failures from definitive answers" do
+ assert Cepis.Error.retriable?(%Cepis.ServerError{status: 500})
+ assert Cepis.Error.retriable?(%Cepis.RateLimitError{})
+ assert Cepis.Error.retriable?(%Cepis.NetworkError{})
+ refute Cepis.Error.retriable?(%Cepis.NotFoundError{})
+ refute Cepis.Error.retriable?(%Cepis.InvalidCEPError{})
+ end
+
+ test "recognises its own errors" do
+ assert Cepis.Error.error?(%Cepis.NotFoundError{})
+ refute Cepis.Error.error?(%RuntimeError{})
+ refute Cepis.Error.error?(:oops)
+ end
+ end
+
+ describe "Cepis.Address.formatted_cep/1" do
+ test "adds the mask" do
+ assert Cepis.Address.formatted_cep(%Cepis.Address{cep: "01001000"}) == "01001-000"
+ end
+
+ test "leaves an unexpected value alone" do
+ assert Cepis.Address.formatted_cep(%Cepis.Address{cep: ""}) == ""
+ end
+ end
+end
diff --git a/test/support/fake_server.ex b/test/support/fake_server.ex
new file mode 100644
index 0000000..bd1fc5a
--- /dev/null
+++ b/test/support/fake_server.ex
@@ -0,0 +1,154 @@
+defmodule Cepis.FakeServer do
+ @moduledoc false
+
+ defstruct [:pid, :agent, :port, :url]
+
+ @type t :: %__MODULE__{pid: pid(), agent: pid(), port: :inet.port_number(), url: String.t()}
+
+ @listen_options [
+ :binary,
+ ip: {127, 0, 0, 1},
+ packet: :http_bin,
+ packet_size: 1_048_576,
+ active: false,
+ reuseaddr: true,
+ backlog: 128
+ ]
+
+ @not_found {404, ~s({"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}),
+ []}
+
+ def start do
+ {:ok, agent} = Agent.start_link(fn -> %{routes: %{}, calls: [], handler: nil} end)
+ parent = self()
+
+ pid =
+ spawn_link(fn ->
+ {:ok, listen} = :gen_tcp.listen(0, @listen_options)
+ {:ok, port} = :inet.port(listen)
+ send(parent, {:listening, self(), port})
+ accept_loop(listen, agent)
+ end)
+
+ port =
+ receive do
+ {:listening, ^pid, port} -> port
+ after
+ 5_000 -> raise "fake server did not start"
+ end
+
+ %__MODULE__{pid: pid, agent: agent, port: port, url: "http://127.0.0.1:#{port}"}
+ end
+
+ def stop(%__MODULE__{} = server) do
+ Process.exit(server.pid, :kill)
+ Process.exit(server.agent, :kill)
+ :ok
+ end
+
+ def respond(%__MODULE__{agent: agent}, path, response) do
+ Agent.update(agent, fn state -> put_in(state.routes[path], response) end)
+ end
+
+ def on_request(%__MODULE__{agent: agent}, fun) when is_function(fun, 2) do
+ Agent.update(agent, fn state -> %{state | handler: fun} end)
+ end
+
+ def calls(%__MODULE__{agent: agent}) do
+ Agent.get(agent, & &1.calls)
+ end
+
+ def count(%__MODULE__{} = server, prefix) do
+ server |> calls() |> Enum.count(&String.starts_with?(&1, prefix))
+ end
+
+ defp accept_loop(listen, agent) do
+ case :gen_tcp.accept(listen) do
+ {:ok, socket} ->
+ worker = spawn(fn -> serve(socket, agent) end)
+ :gen_tcp.controlling_process(socket, worker)
+ send(worker, :ready)
+ accept_loop(listen, agent)
+
+ {:error, _reason} ->
+ :ok
+ end
+ end
+
+ defp serve(socket, agent) do
+ receive do
+ :ready -> :ok
+ after
+ 5_000 -> :ok
+ end
+
+ with {:ok, path} <- read_request(socket) do
+ {status, body, headers} = dispatch(agent, path)
+ :gen_tcp.send(socket, encode(status, body, headers))
+ end
+
+ :gen_tcp.close(socket)
+ end
+
+ defp read_request(socket) do
+ case :gen_tcp.recv(socket, 0, 5_000) do
+ {:ok, {:http_request, _method, {:abs_path, path}, _version}} ->
+ with :ok <- drain_headers(socket), do: {:ok, path}
+
+ other ->
+ other
+ end
+ end
+
+ defp drain_headers(socket) do
+ case :gen_tcp.recv(socket, 0, 5_000) do
+ {:ok, :http_eoh} -> :ok
+ {:ok, {:http_header, _length, _field, _reserved, _value}} -> drain_headers(socket)
+ other -> other
+ end
+ end
+
+ defp dispatch(agent, path) do
+ Agent.get_and_update(agent, fn state ->
+ state = %{state | calls: state.calls ++ [path]}
+ select(state, path)
+ end)
+ end
+
+ defp select(%{handler: handler} = state, path) when is_function(handler, 2) do
+ {handler.(path, length(state.calls)), state}
+ end
+
+ defp select(state, path) do
+ case Map.get(state.routes, path) do
+ nil -> {@not_found, state}
+ [single] -> {single, state}
+ [next | rest] -> {next, put_in(state.routes[path], rest)}
+ response -> {response, state}
+ end
+ end
+
+ defp encode(status, body, headers) do
+ payload = IO.iodata_to_binary(body)
+
+ head = [
+ "HTTP/1.1 #{status} #{reason(status)}\r\n",
+ Enum.map(headers, fn {key, value} -> "#{key}: #{value}\r\n" end),
+ "content-type: text/plain; charset=utf-8\r\n",
+ "content-length: #{byte_size(payload)}\r\n",
+ "connection: close\r\n\r\n"
+ ]
+
+ IO.iodata_to_binary([head, payload])
+ end
+
+ defp reason(200), do: "OK"
+ defp reason(400), do: "Bad Request"
+ defp reason(404), do: "Not Found"
+ defp reason(429), do: "Too Many Requests"
+ defp reason(500), do: "Internal Server Error"
+ defp reason(502), do: "Bad Gateway"
+ defp reason(503), do: "Service Unavailable"
+ defp reason(504), do: "Gateway Timeout"
+ defp reason(_status), do: "Status"
+end
diff --git a/test/support/server_case.ex b/test/support/server_case.ex
new file mode 100644
index 0000000..a64bb05
--- /dev/null
+++ b/test/support/server_case.ex
@@ -0,0 +1,52 @@
+defmodule Cepis.ServerCase do
+ @moduledoc false
+
+ use ExUnit.CaseTemplate
+
+ using do
+ quote do
+ import Cepis.ServerCase
+
+ alias Cepis.FakeServer
+ end
+ end
+
+ setup do
+ server = Cepis.FakeServer.start()
+ on_exit(fn -> Cepis.FakeServer.stop(server) end)
+
+ client =
+ Cepis.Client.new(
+ base_url: server.url,
+ timeout: 5_000,
+ backoff_base: 1,
+ backoff_max: 10
+ )
+
+ %{server: server, client: client}
+ end
+
+ @address %{
+ "cep" => "01001000",
+ "logradouro" => "Praça da Sé",
+ "complemento" => "lado ímpar",
+ "unidade" => "",
+ "bairro" => "Sé",
+ "localidade" => "São Paulo",
+ "uf" => "SP",
+ "estado" => "São Paulo",
+ "regiao" => "Sudeste",
+ "ibge" => "3550308",
+ "gia" => "1004",
+ "ddd" => "11",
+ "siafi" => "7107"
+ }
+
+ @csv_header "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"
+
+ def address_fixture(extra \\ %{}), do: Map.merge(@address, extra)
+
+ def json(term), do: IO.iodata_to_binary(:json.encode(term))
+
+ def csv(rows), do: Enum.join([@csv_header | rows], "\n") <> "\n"
+end
diff --git a/test/test_helper.exs b/test/test_helper.exs
new file mode 100644
index 0000000..869559e
--- /dev/null
+++ b/test/test_helper.exs
@@ -0,0 +1 @@
+ExUnit.start()