summaryrefslogtreecommitdiffstats
path: root/README.md
blob: e13ed1a5a72229f22ba6d63652d2d523c1235a11 (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
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
# cepis

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

```php
$address = Cepis\Client::default()->lookup('01001-000');
echo $address->logradouro, ' ', $address->localidade, ' ', $address->uf;
// Praça da Sé São Paulo SP
```

## Installation

```sh
composer require cepis/cepis
```

Requires PHP 8.1 or newer, with `ext-curl` and `ext-json`. Both ship enabled in
almost every PHP build; they are declared in `composer.json` so a host that
lacks them fails at install time rather than at the first lookup.

## Usage

### A single lookup

`lookup()` accepts the CEP masked or unmasked and returns an `Address`:

```php
use Cepis\Client;

$a = Client::default()->lookup('01310-100');

$a->logradouro;        // "Avenida Paulista"
$a->localidade;        // "São Paulo"
$a->uf;                // "SP"
$a->ibge;              // "3550308"
$a->cep;               // "01310100"  (always unmasked)
$a->formattedCep();    // "01310-100"
```

Fields the database does not have for that CEP come back as empty strings,
never null. `$a->field('name')` reads any field of the raw response, including
one the API grew after this release.

Property names are the API's own JSON keys, in Portuguese, with no
translation — what you read at `https://cep.is/01001000` is what you read on
the object. That includes the two keys that are not single words: they stay
snake_case as the API writes them, `Geo::$raio_m` and `Health::$base_gerada_em`.
Methods are English and camelCase as PHP expects.

### Coordinates

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

```php
if ($a->geo !== null) {
    $a->geo->lat;        // -23.567202
    $a->geo->lon;        // -46.649659
    $a->geo->raio_m;     // 1084
    $a->geo->precisao;   // "logradouro"
}
```

**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 |

`$a->geo->isPrecise()` is the shorthand for `precisao === 'logradouro'`:

```php
if ($a->geo !== null && $a->geo->isPrecise()) {
    $cost = shippingFrom($a->geo->lat, $a->geo->lon);
} else {
    $cost = shippingFromMunicipality($a->ibge);
}
```

### Many CEPs at once

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

```php
$ceps = ['01001000', '99999999', '20040020'];
$result = Client::default()->lookupMany($ceps);

foreach ($result as $i => $address) {
    if ($address === null) {
        printf("%s: not found\n", $ceps[$i]);
    } else {
        printf("%s: %s\n", $ceps[$i], $address->localidade);
    }
}
```

The result has **the same length and the same order** as the input, indexed
from zero. A CEP that does not exist, or is malformed, is `null` at its
position — it does not vanish, and it is not an exception.

Arrays 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 `lookupMany()` has a null `geo`,
> including for CEPs that do have one. Need the coordinate? 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:

```php
$h = Client::default()->health();

$h->isOk();            // 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 an exception — that arrives in
`$h->status`.

## Configuration

For anything beyond the defaults, build a `Client` and reuse it. It keeps no
state between calls. Named arguments make the settings readable at the call
site:

```php
use Cepis\Client;

$client = new Client(
    baseUrl: 'https://cep.is',   // point at another instance
    timeout: 10.0,               // seconds for one attempt
    maxAttempts: 3,              // counting the first; 1 disables retrying
    backoffBase: 0.5,
    backoffMax: 8.0,
    totalTimeout: 15.0,          // cap on the whole call
    userAgent: 'myapp/1.0',
);

$address = $client->lookup('01001000');
```

`timeout` bounds a single attempt — connection, response and body read
together. A call may make several attempts with waits between them; the cap on
all of that is `totalTimeout`, which is unset by default.

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

`Client::default()` is a shared instance with the default settings, for scripts
that need no configuration. It applies process-wide, third-party libraries
included — anyone needing different settings should build their own.

`Client::normalizeCep()` strips the mask and validates the 8 digits on its own,
for checking a form field before spending a request:

```php
Client::normalizeCep(' 01001-000 ');   // "01001000"
Client::normalizeCep('123');           // throws InvalidCepException
```

### Pointing at a local instance

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

```php
$client = new Client(baseUrl: 'http://localhost:8080');
```

### Using another HTTP layer

By default the client talks to the API with `ext-curl` directly. Hosts that
require their own HTTP layer can supply a `Cepis\Transport` instead. The
interface is one method:

```php
public function get(string $url, array $headers, float $timeout): Cepis\Response;
```

It returns a `Cepis\Response` — status, headers and body — for any HTTP status,
and throws `Cepis\NetworkException` only when the request did not complete at
all. Retries, backoff and `Retry-After` stay the client's job, so an
implementation makes exactly one request and never retries.

This is what a **WordPress or WooCommerce plugin** needs: WordPress expects
HTTP to go through the WP HTTP API, which is what gives site owners proxy
support and the `http_request_args` filters they rely on.

```php
final class WpTransport implements Cepis\Transport
{
    public function get(string $url, array $headers, float $timeout): Cepis\Response
    {
        $response = wp_remote_get($url, [
            'headers' => $headers,
            'timeout' => $timeout,
        ]);

        if (is_wp_error($response)) {
            throw new Cepis\NetworkException($response->get_error_message());
        }

        return new Cepis\Response(
            wp_remote_retrieve_response_code($response),
            wp_remote_retrieve_headers($response)->getAll(),
            wp_remote_retrieve_body($response),
        );
    }
}

$client = new Cepis\Client(transport: new WpTransport());
$address = $client->lookup($_POST['billing_postcode']);
```

Header names may be in any case and values may be a single string or a list of
them; `Response::header()` handles both, so whatever your HTTP layer produced
can be passed straight through.

## Errors

Every exception implements `Cepis\CepisException`, so one `catch` handles any
failure. Below that, the split is by **the decision you have to make**, not by
the HTTP status that caused it:

| exception | when | does retrying help? |
| --- | --- | --- |
| `InvalidCepException` | not 8 digits | no |
| `CepNotFoundException` | well-formed, not in the database; may carry `faixa` | no |
| `RateLimitedException` | 429; carries `retryAfter` | yes, later |
| `ServerErrorException` | 5xx; carries `status` | yes, later |
| `NetworkException` | DNS, connection, timeout, TLS | yes, later |
| `InvalidResponseException` | unintelligible response | probably not |

```php
use Cepis\CepisException;
use Cepis\CepNotFoundException;
use Cepis\RateLimitedException;

try {
    $address = Cepis\Client::default()->lookup($cepFromForm);
} catch (CepNotFoundException) {
    // ask the user for another CEP
} catch (RateLimitedException $e) {
    reschedule($e->retryAfter);
} catch (CepisException) {
    // network, server, unreadable response: retry later
}
```

`CepisException` is an interface rather than a base class, so each exception can
also extend the SPL type you would already be catching: `InvalidCepException` is
an `\InvalidArgumentException` and `CepNotFoundException` is an
`\OutOfBoundsException`. Everything else extends `\RuntimeException`.

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

### A CEP that does not exist, but in a city that does

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`, which 957
municipalities never received. So `CepNotFoundException` carries a `Faixa` when
the API can say which municipality the CEP *would* have belonged to:

```php
try {
    $address = Client::default()->lookup('25845-000');
} catch (CepNotFoundException $e) {
    if ($e->faixa !== null) {
        printf(
            "CEP not found in %s — the city has %d CEPs, between %s and %s\n",
            $e->faixa->label(),   // "Areal/RJ"
            $e->faixa->ceps,      // 118
            $e->faixa->cep_ini,   // "25845001"
            $e->faixa->cep_fim,   // "25849899"
        );
    }
}
```

It is still a 404. **`faixa` is context for an error, never an address**: a CEP
that has one is just as absent from the database as one that does not, and
nothing here should turn a not-found into a success. What it buys you is a
message worth reading — *"we could not find that CEP in Areal/RJ"* instead of
*"CEP not found"*.

`$e->faixa` is null whenever the API sent nothing, which is what happens for a
CEP in a band no municipality owns. The field is **omitted, never null**, so
absence is normal; and a `faixa` this client cannot read is also null, because a
malformed block must never cost you the exception you came for.

| field | |
| --- | --- |
| `localidade`, `uf`, `estado`, `regiao`, `ibge` | the municipality, same keys as on `Address` |
| `cep_ini`, `cep_fim` | the smallest and largest CEP that **exist** there |
| `ceps` | how many CEPs the municipality actually has |
| `label()` | `"Areal/RJ"`, for showing to a person |

Two things about the range, both easy to get wrong:

`cep_ini` and `cep_fim` are **not** the bounds the Correios publish. They are
the extremes of what is really in the database. The DNE's own start for Areal is
`25845000` — exactly the CEP that failed above — and it is not `cep_ini`
precisely because it does not exist.

**The range is sparse.** Areal's 118 CEPs are spread across the ~4,900 numbers
between `25845001` and `25849899`; almost all of that interval is empty. That is
what `ceps` is for. Never iterate the interval, never present the pair as the
list of valid CEPs for the city, and never conclude from
`cep_ini <= $x <= cep_fim` that `$x` exists.

## Development

Everything runs through `make`:

```sh
make            # list the targets
make test       # run the suite with PHPUnit
make lint       # php -l over the tree, touches nothing
make format     # apply a formatter (writes)
make build      # validate composer.json and the PSR-4 autoloading
make check      # lint, then test, then build
```

PHPUnit is the only dev dependency and `make test` installs it. The suite
stands up a real HTTP server with `php -S` and points the client at it over a
socket — no network, and nothing stubbed in place of the transport.

The code is PSR-12. `make lint` always runs `php -l` over every file; it also
runs `php-cs-fixer` or `phpcs` when either is installed, but neither is a
dependency of this package, so on a bare checkout `lint` is the syntax check
alone.

## Licence

ISC. See [LICENSE](LICENSE).