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
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
|
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, Faixa, 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.
## When the CEP does not exist
A failing CEP is rarely random — it is usually someone who knows the city and
guessed the number, most often the `-000`, which 957 municipalities never
received. So `Cepis.NotFoundError` may carry a `Cepis.Faixa` naming the
municipality that number *would* have belonged to:
{:error, %Cepis.NotFoundError{faixa: faixa}} = Cepis.lookup("25845000")
if faixa do
# "Areal", "RJ" — the CEP is still not in the database
{faixa.localidade, faixa.uf}
end
It is still a not-found, and `faixa` is context for the failure rather than
an address. It is `nil` whenever the number falls in a band no municipality
owns.
## Errors
* `Cepis.InvalidCEPError` — not 8 digits (no request is made), or the API
rejected it.
* `Cepis.NotFoundError` — well-formed, but not in the database. May carry
the municipality in `faixa`.
* `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
faixa = faixa(body)
{:error,
%NotFoundError{
cep: number,
faixa: faixa,
message: not_found_message(number, faixa)
}}
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
# A 404 body is a courtesy, not a contract: if it is absent, truncated or not
# JSON at all, the not-found still stands on its own. Nothing here may turn a
# clean definitive answer into a parse failure.
defp faixa(body) do
case decode(body) do
{:ok, data} -> Faixa.from_map(Map.get(data, "faixa"))
{:error, _reason} -> nil
end
end
defp not_found_message(number, nil) do
"CEP #{number} does not exist in the DNE database"
end
defp not_found_message(number, %Faixa{} = faixa) do
"CEP #{number} does not exist in the DNE database — the range belongs to " <>
"#{faixa.localidade}/#{faixa.uf}, which has #{faixa.ceps} CEPs between " <>
"#{faixa.cep_ini} and #{faixa.cep_fim}"
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
|