summaryrefslogtreecommitdiffstats
path: root/README.md
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 14:58:55 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 14:58:55 -0300
commit64764fb9fac0227f8e0443e7c7519d73aec275b9 (patch)
treecb4fc56eeb6ecce53854f9c530609cf01ab3349f /README.md
downloadphp-0.1.0.tar.gz
Initial importv0.1.0
Diffstat (limited to 'README.md')
-rw-r--r--README.md291
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).