diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:19:34 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:19:34 -0300 |
| commit | 075683ff44a254e99adbad8a0372ef80ff06151a (patch) | |
| tree | 648289c8485afc6f003a3f719fb937c21f149311 | |
| download | elixir-0.1.0.tar.gz | |
Initial importv0.1.0
| -rw-r--r-- | .formatter.exs | 3 | ||||
| -rw-r--r-- | .gitignore | 9 | ||||
| -rw-r--r-- | LICENSE | 15 | ||||
| -rw-r--r-- | Makefile | 41 | ||||
| -rw-r--r-- | README.md | 203 | ||||
| -rw-r--r-- | lib/cepis.ex | 277 | ||||
| -rw-r--r-- | lib/cepis/address.ex | 101 | ||||
| -rw-r--r-- | lib/cepis/client.ex | 114 | ||||
| -rw-r--r-- | lib/cepis/csv.ex | 65 | ||||
| -rw-r--r-- | lib/cepis/error.ex | 157 | ||||
| -rw-r--r-- | lib/cepis/geo.ex | 85 | ||||
| -rw-r--r-- | lib/cepis/health.ex | 64 | ||||
| -rw-r--r-- | lib/cepis/http.ex | 197 | ||||
| -rw-r--r-- | mix.exs | 38 | ||||
| -rw-r--r-- | test/cepis/batch_test.exs | 98 | ||||
| -rw-r--r-- | test/cepis/csv_test.exs | 49 | ||||
| -rw-r--r-- | test/cepis/health_test.exs | 45 | ||||
| -rw-r--r-- | test/cepis/lookup_test.exs | 152 | ||||
| -rw-r--r-- | test/cepis/retry_test.exs | 90 | ||||
| -rw-r--r-- | test/cepis_test.exs | 79 | ||||
| -rw-r--r-- | test/support/fake_server.ex | 154 | ||||
| -rw-r--r-- | test/support/server_case.ex | 52 | ||||
| -rw-r--r-- | test/test_helper.exs | 1 |
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/ @@ -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 @@ -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() |