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

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

```cobol
WORKING-STORAGE SECTION.
COPY "cepis.cpy".

PROCEDURE DIVISION.
    CALL "CEPIS-DEFAULT-CONFIG" USING CEPIS-CONFIG
    MOVE "01001-000" TO CEPIS-CEP-INPUT
    CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
                              CEPIS-ADDRESS CEPIS-STATUS
    IF CEPIS-OK
        DISPLAY ADDR-LOGRADOURO " " ADDR-LOCALIDADE " " ADDR-UF
    END-IF
```

Targets **GnuCOBOL 3.x**, default dialect, **free source format**
(`cobc -free`). HTTP goes through **libcurl**, reached from a small C shim
in `src/cepis-http.c` rather than from COBOL directly, because
`curl_easy_setopt` is variadic and a COBOL `CALL` cannot express that
portably.

## Installation

```sh
git clone https://git.cep.is/cobol.git cepis-cobol
cd cepis-cobol
make build
```

That produces `build/libcepis.a`. Link it into your program and add the
copybook directory to the search path:

```sh
cobc -x -free -I /path/to/cepis-cobol/copy \
     -o myprog myprog.cob /path/to/cepis-cobol/build/libcepis.a -lcurl
```

Requires GnuCOBOL 3.x, a C compiler and libcurl with its development
headers. `python3` is needed only to run the test suite.

## Usage

Everything is a `CALL` to a subprogram, and everything shares two records
from `cepis.cpy`: `CEPIS-CONFIG` going in and `CEPIS-STATUS` coming back.
`CALL "CEPIS-DEFAULT-CONFIG" USING CEPIS-CONFIG` fills the configuration
with its defaults; override afterwards what you need.

### A single lookup

`CEPIS-LOOKUP` accepts the CEP masked or unmasked:

```cobol
MOVE "01310-100" TO CEPIS-CEP-INPUT
CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
                          CEPIS-ADDRESS CEPIS-STATUS

ADDR-LOGRADOURO   *> 'Avenida Paulista'
ADDR-LOCALIDADE   *> 'São Paulo'
ADDR-UF           *> 'SP'
ADDR-IBGE         *> '3550308'
ADDR-CEP          *> '01310100'   always unmasked
ADDR-CEP-MASKED   *> '01310-100'
```

Fields the database does not have for that CEP come back as spaces.
`CEPIS-ADDRESS` is cleared before every call, so a failed lookup never
leaves the previous CEP's street behind.

### Coordinates

About 57% of CEPs have a coordinate. There is an explicit flag rather than
a marker value:

```cobol
IF ADDR-GEO-KNOWN
    DISPLAY ADDR-GEO-LAT " " ADDR-GEO-LON
    DISPLAY ADDR-GEO-RAIO-M " " ADDR-GEO-PRECISAO
END-IF
```

**Check `ADDR-GEO-PRECISAO` before computing distance.** There are two
kinds of point, and the difference between them is tens of kilometres:

| `precisao` | condition name | typical radius | what the point is |
| --- | --- | --- | --- |
| `logradouro` | `ADDR-GEO-PRECISE` | 110–140 m | the stretch of street — good for maps and distance |
| `localidade` | `ADDR-GEO-CITY-WIDE` | 7–40 km | the municipality centroid: the CEP *is* the whole city |

```cobol
IF ADDR-GEO-KNOWN AND ADDR-GEO-PRECISE
    PERFORM SHIPPING-FROM-POINT
ELSE
    PERFORM SHIPPING-FROM-MUNICIPALITY
END-IF
```

`ADDR-GEO-LAT` and `ADDR-GEO-LON` are fixed-point `S9(3)V9(6)`, six
decimal places — about 11 cm, well past what the data supports.

### Many CEPs at once

Do not loop over `CEPIS-LOOKUP`. `CEPIS-LOOKUP-MANY` resolves up to 500
CEPs per request:

```cobol
MOVE 3 TO BATCH-COUNT
MOVE "01001000" TO BATCH-REQUESTED (1)
MOVE "99999999" TO BATCH-REQUESTED (2)
MOVE "20040020" TO BATCH-REQUESTED (3)

CALL "CEPIS-LOOKUP-MANY" USING CEPIS-CONFIG CEPIS-BATCH CEPIS-STATUS

PERFORM VARYING I FROM 1 BY 1 UNTIL I > BATCH-COUNT
    IF BATCH-ENTRY-FOUND (I)
        DISPLAY BATCH-REQUESTED (I) ": " BAT-LOCALIDADE (I)
    ELSE
        DISPLAY BATCH-REQUESTED (I) ": not found"
    END-IF
END-PERFORM
```

Every slot comes back in place. A CEP that does not exist, or that is not
eight digits, leaves its slot `BATCH-ENTRY-MISSING` — it is never removed,
never shifted, and never fails the call.

The table holds 1000 slots and requests are chunked at 500 automatically.
A `BATCH-COUNT` above 1000 is refused with `CEPIS-INVALID-CEP` rather than
silently truncated.

> **Batches carry no coordinate.** The endpoint behind them is CSV, and the
> CSV has no `geo` column. Every slot has `BAT-GEO-ABSENT` set, including
> CEPs that do have a coordinate. Need `geo`? Use `CEPIS-LOOKUP`, one at a
> time.

### API health

```cobol
CALL "CEPIS-HEALTH" USING CEPIS-CONFIG CEPIS-HEALTH CEPIS-STATUS

HEALTH-OK               *> condition name, true when status is "ok"
HEALTH-DNE              *> '26071'   identifier of the fortnightly database
HEALTH-CEPS             *> 1596168
HEALTH-BASE-GERADA-EM   *> '2026-07-24T03:48:28Z'
```

An API reporting itself degraded does not fail the call — that arrives in
`HEALTH-STATUS` with `CEPIS-OK` set.

### Validating a CEP on its own

```cobol
MOVE "01001-000" TO CEPIS-CEP-INPUT
CALL "CEPIS-NORMALIZE-CEP" USING CEPIS-CEP-INPUT CEPIS-CEP CEPIS-STATUS
*> CEPIS-CEP is now '01001000'
```

## Configuration

| field | default | meaning |
| --- | --- | --- |
| `CFG-BASE-URL` | `https://cep.is` | where the API lives; point it at a local instance |
| `CFG-TIMEOUT-MS` | `10000` | per request: bounds the connect and the transfer |
| `CFG-MAX-ATTEMPTS` | `3` | attempts in total, counting the first; `1` disables retrying |
| `CFG-BACKOFF-BASE-MS` | `500` | base of the exponential wait |
| `CFG-BACKOFF-MAX-MS` | `8000` | cap on each wait, `Retry-After` included |
| `CFG-TOTAL-TIMEOUT-MS` | `0` | cap on the whole call, attempts and waits included; `0` means none |
| `CFG-USER-AGENT` | `cepis-cobol/0.2.0 (+https://cep.is)` | identifies the library and version |

Retries cover 429, 5xx and transport failures, with exponential backoff and
full jitter; 400 and 404 are never retried.

`CFG-BASE-URL` is the only field that usually changes between environments.
The API repo brings a local instance up with `just up`:

```cobol
MOVE "http://localhost:8080" TO CFG-BASE-URL
```

## Errors

`CEPIS-STATUS` carries the outcome. Test `CEPIS-OK` first; `CEPIS-FAILED`
covers every failure at once, and `CEPIS-RETRIABLE` the three the client
already retried on its own before giving up.

| condition | when | does retrying help? |
| --- | --- | --- |
| `CEPIS-INVALID-CEP` | not 8 digits | no |
| `CEPIS-NOT-FOUND` | well-formed, not in the database; see `CEPIS-FAIXA` | no |
| `CEPIS-RATE-LIMITED` | 429; see `CEPIS-STATUS-RETRY-AFTER-MS` | yes, later |
| `CEPIS-SERVER-ERROR` | 5xx; see `CEPIS-STATUS-HTTP` | yes, later |
| `CEPIS-NETWORK-ERROR` | DNS, connection, timeout, TLS | yes, later |
| `CEPIS-INVALID-RESPONSE` | success status, unintelligible body | probably not |

```cobol
EVALUATE TRUE
    WHEN CEPIS-OK          PERFORM USE-THE-ADDRESS
    WHEN CEPIS-NOT-FOUND   PERFORM ASK-FOR-ANOTHER-CEP
    WHEN CEPIS-RETRIABLE   PERFORM RESCHEDULE
    WHEN OTHER             DISPLAY CEPIS-STATUS-MESSAGE
END-EVALUATE
```

`CEPIS-STATUS-MESSAGE` holds a description, `CEPIS-STATUS-HTTP` the last
status seen and `CEPIS-STATUS-ATTEMPTS` how many attempts were made.

`CEPIS-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.

### Where a missing CEP would have been

A `CEPIS-NOT-FOUND` from `CEPIS-LOOKUP` may also carry `CEPIS-FAIXA`: the
municipality the CEP would have belonged to. A CEP that fails is rarely a
random number — it is someone who knows the city and guessed the digits,
and the classic guess is the `-000` that 957 municipalities never received.

```cobol
IF CEPIS-NOT-FOUND AND CEPIS-FAIXA-KNOWN
    DISPLAY "no such CEP -- " FUNCTION TRIM (CEPIS-FAIXA-LOCALIDADE)
            "/" CEPIS-FAIXA-UF " has " CEPIS-FAIXA-CEPS
            " CEPs, from " CEPIS-FAIXA-CEP-INI
            " to " CEPIS-FAIXA-CEP-FIM
END-IF
```

**It is still a 404.** The lookup failed, `CEPIS-ADDRESS` is empty, and
nothing in `CEPIS-FAIXA` is an address for the CEP that was asked for. It
says where the guess landed, not what it resolves to.

`CEPIS-FAIXA-CEP-INI` and `CEPIS-FAIXA-CEP-FIM` are the smallest and the
largest CEP the municipality **actually has**, not the bounds the Correios
publish. The DNE's own start for Areal/RJ is `25845000` — exactly the CEP
that just failed, which is why the range comes back starting one past it,
at `25845001`.

**The span is sparse, and it is not a list.** Areal's 118 CEPs are
scattered over the ~4900 numbers between `25845001` and `25849899`; almost
none of that interval exists either. That is what `CEPIS-FAIXA-CEPS` is
for — show it next to the pair, and never iterate the two as a range.

The API omits `faixa` — it never sends it as null — when the CEP falls in a
band no municipality owns, so **test `CEPIS-FAIXA-KNOWN` before reading
anything below it.** A `faixa` that is missing, malformed, or short of its
members leaves the flag off and the not-found otherwise untouched; a
decoration on an error is never worth failing that error over.

Only `CEPIS-LOOKUP` fills it. `CEPIS-LOOKUP-MANY` and `CEPIS-HEALTH` clear
it, so `CEPIS-FAIXA-ABSENT` holds after a batch or a health call and a
faixa never survives from one lookup into the next.

| field | bytes | | field | bytes |
| --- | --- | --- | --- | --- |
| `faixa-localidade` | 100 | | `faixa-ibge` | 10 |
| `faixa-uf` | 2 | | `faixa-cep-ini` | 8 |
| `faixa-estado` | 60 | | `faixa-cep-fim` | 8 |
| `faixa-regiao` | 30 | | `faixa-ceps` | `9(9)` |

## Field widths

COBOL records are fixed width. A value longer than its field is truncated
on the right and `ADDR-TRUNCATED` (or `BAT-TRUNCATED`, per slot) is set, so
truncation is visible rather than silent. Lengths are in bytes and the data
is UTF-8, so an accented character costs two.

| field | bytes | | field | bytes |
| --- | --- | --- | --- | --- |
| `cep` | 8 | | `uf` | 2 |
| `cep-masked` | 9 | | `estado` | 60 |
| `logradouro` | 120 | | `regiao` | 30 |
| `complemento` | 120 | | `ibge` | 10 |
| `unidade` | 80 | | `gia` | 10 |
| `bairro` | 100 | | `ddd` | 4 |
| `localidade` | 100 | | `siafi` | 10 |

A response body larger than 256 KiB is reported as
`CEPIS-INVALID-RESPONSE`; 500 CSV rows come to roughly 60 KiB.

## Limitations

- **One call at a time per process.** A GnuCOBOL subprogram's
  `WORKING-STORAGE` is static, so the subprograms here are not reentrant.
- The raw response body is not kept. Fields the client does not know are
  ignored without error, but they are not readable afterwards.
- `CFG-TIMEOUT-MS` maps onto libcurl's connect and transfer timeouts, not
  onto each individual socket read.

## Development

```sh
make            # list the targets
make build      # compile the library into build/libcepis.a
make test       # run the suite against a local fake API
make lint       # syntax and warning check, writes nothing
make check      # lint, then test, then build
make clean      # remove build artefacts
```

`make test` stands a fake cep.is up on a loopback port with
`tests/server.py`, points `CFG-BASE-URL` at it and runs the driver in
`tests/cepis-tests.cob`. No test touches the network. The run exits
non-zero if any check fails.

COBOL has no standard formatter, so there is no `format` target; `lint`
compiles every unit with `-Wall -fsyntax-only`.

## Licence

ISC. See [LICENSE](LICENSE).