summaryrefslogtreecommitdiffstats
path: root/README.md
blob: 94b5daa0170c5c72033c86628e4a16c8c8749505 (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
# cepis

OCaml client for the [public CEP API at cep.is](https://cep.is). Official
Correios data (DNE), refreshed every two weeks.

```ocaml
match Cepis.lookup "01001-000" with
| Ok a -> Printf.printf "%s %s %s\n" a.Cepis.logradouro a.Cepis.localidade a.Cepis.uf
(* Praça da Sé São Paulo SP *)
| Error e -> prerr_endline (Cepis.string_of_error e)
```

## Installation

```sh
opam pin add cepis git+https://git.cep.is/ocaml.git
```

Requires OCaml 4.14 or newer.

Three dependencies, each because the standard library has no equivalent:
[`ezcurl`](https://github.com/c-cube/ezcurl) for HTTP, because it is
synchronous and does not force an async runtime on callers;
[`yojson`](https://github.com/ocaml-community/yojson) for JSON, the
conventional choice; and [`csv`](https://github.com/Chris00/ocaml-csv) for the
batch endpoint, whose values may be quoted and contain commas. `ezcurl` binds
libcurl, so its development headers must be present; opam installs them for
you on most systems.

## Usage

### A single lookup

`lookup` accepts the CEP masked or unmasked and returns an `address`:

```ocaml
let a = Result.get_ok (Cepis.lookup "01310-100") in
a.Cepis.logradouro       (* "Avenida Paulista" *)
a.Cepis.localidade       (* "São Paulo" *)
a.Cepis.uf               (* "SP" *)
a.Cepis.ibge             (* "3550308" *)
a.Cepis.cep              (* "01310100"  (always unmasked) *)
Cepis.formatted_cep a    (* "01310-100" *)
```

Fields the database does not have for that CEP come back as empty strings.

### Coordinates

About 57% of CEPs have a coordinate. When they do not, `geo` is `None`:

```ocaml
match a.Cepis.geo with
| Some g -> Printf.printf "%f %f %d %s\n" g.Cepis.lat g.Cepis.lon g.Cepis.raio_m g.Cepis.precisao
| None -> ()
```

**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.is_precise` is the shorthand for `precisao = "logradouro"`:

```ocaml
match a.Cepis.geo with
| Some g when Cepis.is_precise g -> shipping_from g.Cepis.lat g.Cepis.lon
| _ -> shipping_from_municipality a.Cepis.ibge
```

### Many CEPs at once

Do not loop over `lookup`. Use `lookup_many`, which resolves up to 500 CEPs
per request:

```ocaml
let ceps = [ "01001000"; "99999999"; "20040020" ] in
match Cepis.lookup_many ceps with
| Ok rows ->
  List.iter2
    (fun cep -> function
      | None -> Printf.printf "%s: not found\n" cep
      | Some a -> Printf.printf "%s: %s\n" cep a.Cepis.localidade)
    ceps rows
| Error e -> prerr_endline (Cepis.string_of_error e)
```

The list returned has **the same length and the same order** as the input. A
CEP that does not exist, or is malformed, becomes `None` 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 address from `lookup_many` has `geo = None`,
> including for CEPs that do have one. Need `geo`? Use `lookup`, one at a time.

### API health

Useful for two things: checking the API answers, and knowing **which version
of the database** is live:

```ocaml
let h = Result.get_ok (Cepis.health ()) in
Cepis.is_ok h            (* true *)
h.Cepis.dne              (* "26071"   identifier of the fortnightly database *)
h.Cepis.ceps             (* 1596168 *)
h.Cepis.base_gerada_em   (* "2026-07-24T03:48:28Z" *)
```

An API reporting itself degraded is not an error — that arrives in `h.status`.

## Configuration

For anything beyond the defaults, build a `Cepis.Client.t` and reuse it. It
keeps no state between calls:

```ocaml
let client =
  Cepis.Client.create
    ~base_url:"https://cep.is"  (* point at another instance *)
    ~timeout:10.0               (* seconds for one attempt, connection included *)
    ~max_attempts:3             (* counting the first; 1 disables retrying *)
    ~backoff_base:0.5           (* base of the exponential wait *)
    ~backoff_max:8.0            (* cap on each wait *)
    ~total_timeout:15.0         (* cap on the whole call, attempts included *)
    ()

let address = Cepis.Client.lookup client "01001000"
```

Retries cover 429, 5xx and network failures, with exponential backoff and
jitter; 400 and 404 are never retried. See `Cepis.Client.create` in the
interface for the details of each parameter.

The module-level functions (`Cepis.lookup`, etc.) share a default client built
with the defaults. Need a different configuration? Build your own.

### Pointing at a local instance

`base_url` is the only parameter that usually changes between environments.
The API repo brings a local instance up with `just up`:

```ocaml
let client = Cepis.Client.create ~base_url:"http://localhost:8080" ()
```

## Errors

Expected failures come back as `('a, Cepis.error) result`; only a nonsensical
configuration raises, as `Invalid_argument`. The variants split by **the
decision you have to make**, not by the HTTP status that caused it:

| error | when | does retrying help? |
| --- | --- | --- |
| `Invalid_cep` | not 8 digits | no |
| `Cep_not_found` | well-formed, not in the database; may carry `faixa` | no |
| `Rate_limited` | 429; carries `retry_after` | yes, later |
| `Server_error` | 5xx; carries `status` | yes, later |
| `Network_error` | DNS, connection, timeout, TLS | yes, later |
| `Invalid_response` | unintelligible response | probably not |

```ocaml
match Cepis.lookup cep_from_form with
| Ok address -> use address
| Error (Cepis.Cep_not_found _) -> ask_for_another_cep ()
| Error _ -> reschedule ()
```

### Not found: which city it would have been

A failing CEP is rarely random. It is usually someone who knows the city and
guessed the number — and the classic guess is the `-000` that 957
municipalities never received. So `Cep_not_found` carries a `faixa` naming the
municipality whose band that number falls into, when it falls into one:

```ocaml
match Cepis.lookup "25845000" with
| Error (Cepis.Cep_not_found { faixa = Some f; _ }) ->
  Printf.printf "not found — did you mean somewhere in %s/%s? (%d CEPs, %s to %s)\n"
    f.Cepis.localidade f.Cepis.uf f.Cepis.ceps f.Cepis.cep_ini f.Cepis.cep_fim
  (* not found — did you mean somewhere in Areal/RJ? (118 CEPs, 25845001 to 25849899) *)
| Error (Cepis.Cep_not_found { faixa = None; _ }) -> ask_for_another_cep ()
| _ -> ()
```

Three things this is not:

- **It is still not found.** The lookup failed; `faixa` is context on the
  error, never an address. Nothing here says the CEP exists.
- **`cep_ini`/`cep_fim` are not the Correios' published bounds.** They are the
  smallest and largest CEP that *actually exist* in that municipality. The
  DNE's own start for Areal is `25845000` — exactly the CEP that just failed.
- **The pair is not a list of valid CEPs.** The range is sparse: Areal's 118
  CEPs are scattered across the ~4,900 numbers between `25845001` and
  `25849899`. That is what `ceps` is for. Rendering it as "valid from … to …"
  would be wrong for about 98% of the interval.

`faixa` is `None` when the CEP lands in a band no municipality owns — the API
omits the field rather than sending null — and also whenever it cannot be read.
A 404 is a clean answer, and no shape of `faixa` turns it into an
`Invalid_response`.

`Invalid_response` 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

```sh
make            # list the targets
make test       # run the suite against a local HTTP server
make lint       # ocamlformat check, touches nothing
make build      # build the library
make check      # lint, then test, then build
make doc        # odoc documentation
```

## Licence

ISC. See [LICENSE](LICENSE).