diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 14:58:55 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 14:58:55 -0300 |
| commit | 64764fb9fac0227f8e0443e7c7519d73aec275b9 (patch) | |
| tree | cb4fc56eeb6ecce53854f9c530609cf01ab3349f /README.md | |
| download | php-64764fb9fac0227f8e0443e7c7519d73aec275b9.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'README.md')
| -rw-r--r-- | README.md | 291 |
1 files changed, 291 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d26dbd --- /dev/null +++ b/README.md @@ -0,0 +1,291 @@ +# 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 | 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. + +## 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). |