summaryrefslogtreecommitdiffstats
path: root/lib/cepis.ex
blob: 249e3f197befe9f8879e75777a9bc7e2879a328a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
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