summaryrefslogtreecommitdiffstats
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
downloadphp-0.1.0.tar.gz
Initial importv0.1.0
-rw-r--r--.gitignore7
-rw-r--r--LICENSE15
-rw-r--r--Makefile77
-rw-r--r--README.md291
-rw-r--r--composer.json42
-rw-r--r--phpunit.xml.dist21
-rw-r--r--src/Address.php133
-rw-r--r--src/CepNotFoundException.php15
-rw-r--r--src/CepisException.php34
-rw-r--r--src/Client.php549
-rw-r--r--src/CurlTransport.php85
-rw-r--r--src/Geo.php94
-rw-r--r--src/Health.php76
-rw-r--r--src/InvalidCepException.php19
-rw-r--r--src/InvalidResponseException.php16
-rw-r--r--src/NetworkException.php16
-rw-r--r--src/RateLimitedException.php25
-rw-r--r--src/Response.php50
-rw-r--r--src/ServerErrorException.php22
-rw-r--r--src/Transport.php45
-rw-r--r--tests/BatchTest.php139
-rw-r--r--tests/ConfigurationTest.php60
-rw-r--r--tests/HealthTest.php41
-rw-r--r--tests/LookupTest.php154
-rw-r--r--tests/NormalizationTest.php44
-rw-r--r--tests/RecordingTransport.php39
-rw-r--r--tests/RetryTest.php127
-rw-r--r--tests/ServerTestCase.php106
-rw-r--r--tests/StreamTransport.php52
-rw-r--r--tests/TestServer.php148
-rw-r--r--tests/TransportTest.php141
-rw-r--r--tests/server/router.php53
32 files changed, 2736 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..58a34a7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+/vendor/
+/composer.lock
+/.phpunit.cache/
+/.php-cs-fixer.cache
+/phpunit.xml
+/build/
+/coverage/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..bec4ef6
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,77 @@
+# Development tasks.
+#
+# make list the targets
+# make test run the suite with PHPUnit
+# make lint syntax and style checks, no files touched
+# make build validate the package and its PSR-4 autoloading
+#
+# The library itself has no Composer dependencies; PHPUnit is needed only to
+# run the suite, and `make test` installs it for you.
+
+PHP ?= php
+COMPOSER ?= composer
+
+SOURCES = $(shell find src tests -name '*.php')
+
+# php-cs-fixer and phpcs are not dependencies of this package. Either one is
+# used when it happens to be installed, from vendor/bin or from PATH.
+FIXER = $(shell if [ -x vendor/bin/php-cs-fixer ]; then echo vendor/bin/php-cs-fixer; \
+ else command -v php-cs-fixer 2>/dev/null; fi)
+PHPCS = $(shell if [ -x vendor/bin/phpcs ]; then echo vendor/bin/phpcs; \
+ else command -v phpcs 2>/dev/null; fi)
+PHPCBF = $(shell if [ -x vendor/bin/phpcbf ]; then echo vendor/bin/phpcbf; \
+ else command -v phpcbf 2>/dev/null; fi)
+
+.DEFAULT_GOAL := help
+.PHONY: help test lint format build check clean
+
+help:
+ @echo 'Targets:'
+ @echo ' test run the test suite'
+ @echo ' lint php -l over the tree, plus php-cs-fixer or phpcs when installed'
+ @echo ' format apply php-cs-fixer or phpcbf, when installed (writes)'
+ @echo ' build validate composer.json and the PSR-4 autoloading'
+ @echo ' check lint + test + build, in that order'
+ @echo ' clean remove installed dependencies and test artefacts'
+
+vendor: composer.json
+ $(COMPOSER) install --no-interaction
+ @touch vendor
+
+test: vendor
+ $(PHP) vendor/bin/phpunit
+
+# Read-only by design, so it is safe in CI and in a pre-commit hook: it reports
+# what `make format` would change without changing anything.
+lint:
+ @set -e; for f in $(SOURCES); do $(PHP) -l $$f >/dev/null; done
+ @echo 'syntax OK: $(words $(SOURCES)) files'
+ifneq ($(FIXER),)
+ $(FIXER) fix --dry-run --diff
+else ifneq ($(PHPCS),)
+ $(PHPCS) --standard=PSR12 src tests
+else
+ @echo 'php-cs-fixer and phpcs not installed: syntax checks only'
+endif
+
+format:
+ifneq ($(FIXER),)
+ $(FIXER) fix
+else ifneq ($(PHPCBF),)
+ $(PHPCBF) --standard=PSR12 src tests
+else
+ @echo 'php-cs-fixer and phpcbf not installed: nothing to format with'
+endif
+
+# PHP compiles nothing, so the equivalent of a build is proving the package
+# metadata is valid and that every class is where PSR-4 says it is.
+build:
+ $(COMPOSER) validate --strict
+ $(COMPOSER) dump-autoload --optimize --strict-psr
+
+# The order matters: lint is the fastest to fail, so a broken run reports the
+# cheapest problem first.
+check: lint test build
+
+clean:
+ rm -rf vendor .phpunit.cache .php-cs-fixer.cache
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).
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..e4ea8cc
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,42 @@
+{
+ "name": "cepis/cepis",
+ "description": "PHP client for the public CEP API at cep.is: official Correios data (DNE), single lookups with coordinates, batches of up to 500, retries with jittered backoff. No runtime dependencies.",
+ "type": "library",
+ "keywords": ["cep", "correios", "dne", "postal-code", "brazil", "address"],
+ "homepage": "https://cep.is",
+ "license": "ISC",
+ "authors": [
+ {
+ "name": "Murilo Ijanc",
+ "email": "[email protected]"
+ }
+ ],
+ "support": {
+ "issues": "https://cep.is",
+ "source": "https://git.cep.is/php.git"
+ },
+ "require": {
+ "php": ">=8.1",
+ "ext-curl": "*",
+ "ext-json": "*"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.5"
+ },
+ "autoload": {
+ "psr-4": {
+ "Cepis\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Cepis\\Tests\\": "tests/"
+ }
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "scripts": {
+ "test": "phpunit"
+ }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..a067123
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
+ bootstrap="vendor/autoload.php"
+ colors="true"
+ failOnWarning="true"
+ failOnNotice="true"
+ failOnDeprecation="true"
+ beStrictAboutOutputDuringTests="true"
+ cacheDirectory=".phpunit.cache">
+ <testsuites>
+ <testsuite name="cepis">
+ <directory>tests</directory>
+ </testsuite>
+ </testsuites>
+ <source>
+ <include>
+ <directory>src</directory>
+ </include>
+ </source>
+</phpunit>
diff --git a/src/Address.php b/src/Address.php
new file mode 100644
index 0000000..8b970aa
--- /dev/null
+++ b/src/Address.php
@@ -0,0 +1,133 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * A CEP and everything the database knows about it.
+ *
+ * **Property names are the API's own JSON keys, in Portuguese** —
+ * `logradouro`, `bairro`, `localidade`. Everything else in this library is
+ * English; the data fields are the one deliberate exception, so that what you
+ * read in the JSON at https://cep.is/01001000 is exactly what you read on the
+ * object, with no translation layer and no field-by-field mapping to keep in
+ * your head. The values are Portuguese either way — they come from the
+ * Correios database.
+ *
+ * Fields the database does not have for that CEP come back as empty strings,
+ * never `null` — that is what the API returns.
+ *
+ * {@see Address::$geo} is the exception: it is `null` when there is no
+ * coordinate. There the difference is real, because the API **omits** the
+ * whole field in that case (about 43% of CEPs) rather than sending null.
+ *
+ * {@see Address::$raw} holds the response exactly as it arrived,
+ * uninterpreted, so a field the API grows before this library is updated is
+ * not lost.
+ */
+final class Address
+{
+ /**
+ * The JSON keys mapped onto properties, in the API's own order.
+ */
+ public const FIELDS = [
+ 'cep', 'logradouro', 'complemento', 'unidade', 'bairro', 'localidade',
+ 'uf', 'estado', 'regiao', 'ibge', 'gia', 'ddd', 'siafi',
+ ];
+
+ /**
+ * @param array<string, mixed> $raw
+ */
+ public function __construct(
+ public readonly string $cep = '',
+ public readonly string $logradouro = '',
+ public readonly string $complemento = '',
+ public readonly string $unidade = '',
+ public readonly string $bairro = '',
+ public readonly string $localidade = '',
+ public readonly string $uf = '',
+ public readonly string $estado = '',
+ public readonly string $regiao = '',
+ public readonly string $ibge = '',
+ public readonly string $gia = '',
+ public readonly string $ddd = '',
+ public readonly string $siafi = '',
+ public readonly ?Geo $geo = null,
+ public readonly array $raw = [],
+ ) {
+ }
+
+ /**
+ * Build from the decoded response.
+ *
+ * Unknown fields are ignored during construction but preserved in
+ * {@see Address::$raw}. A new field in the API therefore cannot break this
+ * library.
+ *
+ * @param array<string, mixed> $data
+ */
+ public static function fromArray(array $data): self
+ {
+ $values = [];
+ foreach (self::FIELDS as $name) {
+ $value = $data[$name] ?? '';
+ $values[$name] = is_scalar($value) ? (string) $value : '';
+ }
+
+ return new self(
+ cep: $values['cep'],
+ logradouro: $values['logradouro'],
+ complemento: $values['complemento'],
+ unidade: $values['unidade'],
+ bairro: $values['bairro'],
+ localidade: $values['localidade'],
+ uf: $values['uf'],
+ estado: $values['estado'],
+ regiao: $values['regiao'],
+ ibge: $values['ibge'],
+ gia: $values['gia'],
+ ddd: $values['ddd'],
+ siafi: $values['siafi'],
+ geo: Geo::fromArray($data['geo'] ?? null),
+ raw: $data,
+ );
+ }
+
+ /**
+ * Read any field of the raw response, including one the API grew after
+ * this release, or `null` when it is absent.
+ */
+ public function field(string $name): ?string
+ {
+ $value = $this->raw[$name] ?? null;
+
+ return is_scalar($value) ? (string) $value : null;
+ }
+
+ /**
+ * The CEP with its mask: `01001-000`.
+ *
+ * The API always returns it unmasked; formatting is presentation, and it
+ * lives here so it does not become a repeated `sprintf` in every project
+ * that uses this.
+ */
+ public function formattedCep(): string
+ {
+ return substr($this->cep, 0, 5) . '-' . substr($this->cep, 5, 3);
+ }
+
+ /**
+ * @return array<string, mixed>
+ */
+ public function toArray(): array
+ {
+ $out = [];
+ foreach (self::FIELDS as $name) {
+ $out[$name] = $this->{$name};
+ }
+ $out['geo'] = $this->geo?->toArray();
+
+ return $out;
+ }
+}
diff --git a/src/CepNotFoundException.php b/src/CepNotFoundException.php
new file mode 100644
index 0000000..c64de8b
--- /dev/null
+++ b/src/CepNotFoundException.php
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The CEP is well-formed but does not exist in the DNE database.
+ *
+ * Extends `\OutOfBoundsException`, the SPL type for a value that is not a
+ * valid key: looking a CEP up is conceptually a key lookup.
+ */
+final class CepNotFoundException extends \OutOfBoundsException implements CepisException
+{
+}
diff --git a/src/CepisException.php b/src/CepisException.php
new file mode 100644
index 0000000..efd1a86
--- /dev/null
+++ b/src/CepisException.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * Implemented by every exception this library throws. Catch it to handle any
+ * failure with one handler.
+ *
+ * It is an interface rather than a base class so that each concrete exception
+ * can also extend the SPL type a PHP developer would already be catching:
+ * {@see InvalidCepException} is an `\InvalidArgumentException`, so code that
+ * already validates input keeps working without knowing this library exists,
+ * and {@see CepNotFoundException} is an `\OutOfBoundsException`, because
+ * looking a CEP up is conceptually a key lookup. PHP has single inheritance;
+ * a marker interface is the only way to have both.
+ *
+ * Below this interface the split is by **the decision the caller has to
+ * make**, not by the HTTP status that caused it:
+ *
+ * - {@see InvalidCepException} and {@see CepNotFoundException} are problems
+ * with the *request*. Retrying is pointless — the same CEP will fail again.
+ * - {@see NetworkException}, {@see ServerErrorException} and
+ * {@see RateLimitedException} are problems with *transport*. Retrying may
+ * help, and the client already retried on its own before throwing.
+ *
+ * That split is why `CepNotFoundException` is not "the HTTP 404 exception":
+ * `InvalidCepException` is thrown without any request being made at all, when
+ * the CEP is malformed before it ever leaves the machine.
+ */
+interface CepisException extends \Throwable
+{
+}
diff --git a/src/Client.php b/src/Client.php
new file mode 100644
index 0000000..4e24b7f
--- /dev/null
+++ b/src/Client.php
@@ -0,0 +1,549 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * Client for the public CEP API at https://cep.is.
+ *
+ * Reuse one instance: the configuration is immutable and no method keeps state
+ * between calls.
+ *
+ * ```php
+ * $client = new Cepis\Client(timeout: 5.0, totalTimeout: 15.0);
+ * echo $client->lookup('01001-000')->localidade; // São Paulo
+ * ```
+ *
+ * For a script, {@see Client::default()} is the shared, default-configured
+ * instance:
+ *
+ * ```php
+ * echo Cepis\Client::default()->lookup('01001-000')->uf; // SP
+ * ```
+ *
+ * Retries cover 429, the transient 5xx in {@see Client::RETRIABLE_STATUS} and
+ * network failures, with exponential backoff and full jitter. 400 and 404 are
+ * deliberately absent from that set: both are definitive answers about the
+ * request, so retrying returns the same thing more slowly while burning
+ * someone else's quota.
+ */
+final class Client
+{
+ public const VERSION = '0.1.0';
+
+ /**
+ * Where the API lives, unless `baseUrl` says otherwise.
+ */
+ public const DEFAULT_URL = 'https://cep.is';
+
+ /**
+ * Most CEPs the API accepts in a single batch request.
+ */
+ public const MAX_BATCH = 500;
+
+ /**
+ * Statuses worth trying again.
+ */
+ public const RETRIABLE_STATUS = [429, 500, 502, 503, 504];
+
+ /**
+ * Where the API lives. A trailing slash is stripped, so it cannot produce
+ * doubled separators that some proxies normalise and others 404.
+ */
+ public readonly string $baseUrl;
+
+ /**
+ * Identifies the library and its version to the server.
+ */
+ public readonly string $userAgent;
+
+ /**
+ * How the GET is actually performed. {@see CurlTransport} unless the
+ * caller supplied one.
+ */
+ public readonly Transport $transport;
+
+ private static ?self $default = null;
+
+ /**
+ * @param string $baseUrl Where the API lives. Point it at a local instance
+ * (the API repo brings one up on `http://localhost:8080` with
+ * `just up`) or at a test server. It is the only setting that usually
+ * changes between environments.
+ * @param float $timeout Seconds to allow for **a single attempt** —
+ * connection, response and body read together. It is not a cap on the
+ * whole call; that cap is `$totalTimeout`.
+ * @param int $maxAttempts How many attempts in total, counting the first.
+ * `1` disables retrying. The default of `3` covers the common case
+ * (one transient failure) without turning a real outage into a long
+ * wait.
+ * @param float $backoffBase Base of the exponential wait, in seconds. The
+ * actual wait is drawn from `[0, base * 2 ** (n - 1)]` — *full
+ * jitter*. The randomness is not decoration: without it, N clients
+ * that fail together come back together and reproduce the spike that
+ * took the service down.
+ * @param float $backoffMax Cap on each individual wait, in seconds. It
+ * applies to a server-sent `Retry-After` too, or a
+ * `Retry-After: 3600` from a misconfigured proxy would park the
+ * process for an hour.
+ * @param float|null $totalTimeout Cap on the whole call, attempts and
+ * waits included. `null` leaves `$maxAttempts` as the only bound. Set
+ * it in code serving a web request: without it the worst case is
+ * `$maxAttempts * $timeout` plus the sum of the backoffs.
+ * @param string|null $userAgent Overrides the `User-Agent`. The default
+ * identifies the library and its version, which is what lets the
+ * server diagnose an old client misbehaving.
+ * @param Transport|null $transport Overrides how the GET is performed.
+ * Supply one to route requests through the host application's HTTP
+ * layer — a WordPress plugin passes an implementation over
+ * `wp_remote_get`. `null` uses curl directly.
+ *
+ * @throws \InvalidArgumentException a setting is out of range.
+ */
+ public function __construct(
+ string $baseUrl = self::DEFAULT_URL,
+ public readonly float $timeout = 10.0,
+ public readonly int $maxAttempts = 3,
+ public readonly float $backoffBase = 0.5,
+ public readonly float $backoffMax = 8.0,
+ public readonly ?float $totalTimeout = null,
+ ?string $userAgent = null,
+ ?Transport $transport = null,
+ ) {
+ if ($maxAttempts < 1) {
+ throw new \InvalidArgumentException('maxAttempts must be >= 1');
+ }
+ if ($timeout <= 0) {
+ throw new \InvalidArgumentException('timeout must be > 0');
+ }
+ if ($backoffBase < 0 || $backoffMax < 0) {
+ throw new \InvalidArgumentException('backoffBase and backoffMax must be >= 0');
+ }
+
+ $this->baseUrl = rtrim($baseUrl, '/');
+ $this->userAgent = $userAgent ?? sprintf('cepis-php/%s (+https://cep.is)', self::VERSION);
+ $this->transport = $transport ?? new CurlTransport();
+ }
+
+ /**
+ * The shared, default-configured client.
+ *
+ * Built once because {@see Client} keeps no state between calls. It
+ * applies process-wide, third-party libraries that also use this package
+ * included. Anyone needing different settings should build their own with
+ * `new Client(...)`.
+ */
+ public static function default(): self
+ {
+ return self::$default ??= new self();
+ }
+
+ /**
+ * Return the CEP's 8 digits, accepting the mask.
+ *
+ * `"01001-000"`, `"01001000"` and `" 01001-000 "` all become
+ * `"01001000"`.
+ *
+ * This runs **before** any request: a 7-digit CEP is the caller's bug, and
+ * spending a round trip for the server to say so wastes both sides' time.
+ *
+ * @throws InvalidCepException unless exactly 8 digits remain.
+ */
+ public static function normalizeCep(string $value): string
+ {
+ $cleaned = str_replace(['-', '.', ' '], '', trim($value));
+ if (preg_match('/^\d{8}$/', $cleaned) !== 1) {
+ throw new InvalidCepException(sprintf('CEP must be 8 digits, got "%s"', $value));
+ }
+
+ return $cleaned;
+ }
+
+ /**
+ * Look up a single CEP, masked or unmasked.
+ *
+ * ```php
+ * $client->lookup('01001-000')->localidade; // "São Paulo"
+ * ```
+ *
+ * A 400 from the API is surfaced as {@see InvalidCepException} carrying
+ * the API's own message: the format already passed local validation, so
+ * the server knows something this client does not.
+ *
+ * @throws InvalidCepException the CEP is not 8 digits (no request is made).
+ * @throws CepNotFoundException well-formed, but not in the database.
+ * @throws RateLimitedException 429 after every attempt.
+ * @throws ServerErrorException 5xx after every attempt.
+ * @throws NetworkException the request never completed.
+ * @throws InvalidResponseException a success status with an unreadable body.
+ */
+ public function lookup(string $cep): Address
+ {
+ $number = self::normalizeCep($cep);
+ [$status, $body] = $this->request('/' . $number);
+
+ if ($status === 404) {
+ throw new CepNotFoundException(
+ sprintf('CEP %s does not exist in the DNE database', $number),
+ );
+ }
+ if ($status === 400) {
+ throw new InvalidCepException(
+ self::errorMessage($body, sprintf('CEP %s rejected', $number)),
+ );
+ }
+
+ return Address::fromArray($this->decodeJsonObject($body));
+ }
+
+ /**
+ * Look up many CEPs in one batch.
+ *
+ * Returns an array of **the same length and in the same order** as the
+ * input, re-indexed from zero. A CEP that does not exist becomes `null` at
+ * its position — it does not vanish from the array, and it does not throw.
+ * That is the right difference from {@see Client::lookup()}: in a batch of
+ * a thousand, one bad CEP must not cost the other nine hundred and
+ * ninety-nine.
+ *
+ * A malformed CEP also becomes `null`, for the same reason, rather than
+ * throwing {@see InvalidCepException}. To know *which* one was malformed,
+ * validate first with {@see Client::normalizeCep()}.
+ *
+ * Input is chunked into blocks of {@see Client::MAX_BATCH} (the API's
+ * limit) automatically. Do not loop over {@see Client::lookup()}: a loop of
+ * 500 calls pays 500 TLS handshakes, the batch pays one.
+ *
+ * **Batches carry no coordinate.** The endpoint behind them is CSV, and
+ * the CSV has no `geo` column. Every {@see Address} returned here has a
+ * `null` `geo`, including for CEPs that do have one. Need the coordinate?
+ * Use {@see Client::lookup()}, one at a time.
+ *
+ * @param list<string> $ceps
+ *
+ * @return list<Address|null>
+ */
+ public function lookupMany(array $ceps): array
+ {
+ if ($ceps === []) {
+ return [];
+ }
+
+ $result = [];
+ foreach (array_chunk(array_values($ceps), self::MAX_BATCH) as $chunk) {
+ foreach ($this->lookupChunk($chunk) as $address) {
+ $result[] = $address;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Query `GET /health`: does the API answer, and with which database.
+ *
+ * Does not throw for an API reporting itself degraded — that arrives in
+ * {@see Health::$status}, and checking health is exactly when you want the
+ * diagnosis rather than an exception. Network failures still throw.
+ */
+ public function health(): Health
+ {
+ [, $body] = $this->request('/health', [200, 503]);
+
+ return Health::fromArray($this->decodeJsonObject($body));
+ }
+
+ /**
+ * One chunk of at most {@see Client::MAX_BATCH} CEPs, in a single request.
+ *
+ * A blank CSV row means "not found": the API fills only the `cep` column
+ * and leaves the rest empty, precisely so it does not misalign whoever is
+ * consuming the batch in a spreadsheet.
+ *
+ * The API guarantees one row per requested CEP. Should that ever fail to
+ * hold — a proxy truncating, a cut response — the result is padded to the
+ * input length rather than returned short, because a shifted array
+ * silently pairs the wrong address with the wrong CEP.
+ *
+ * @param list<string> $ceps
+ *
+ * @return list<Address|null>
+ */
+ private function lookupChunk(array $ceps): array
+ {
+ $query = http_build_query(['ceps' => implode(',', $ceps)]);
+ [, $body] = $this->request('/csv?' . $query);
+
+ $rows = [];
+ foreach ($this->parseCsv($body) as $row) {
+ $rows[] = trim($row['localidade'] ?? '') === '' ? null : Address::fromArray($row);
+ }
+
+ $wanted = count($ceps);
+ if (count($rows) !== $wanted) {
+ $rows = array_slice(array_pad($rows, $wanted, null), 0, $wanted);
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Parse the CSV batch body into one associative row per record.
+ *
+ * `fgetcsv` over an in-memory stream is PHP's own RFC 4180 parser: it
+ * handles the quoted fields the API emits for values containing a comma,
+ * the `""` escape inside them, and a quoted field spanning a newline —
+ * which splitting on `\n` and calling `str_getcsv` per line would not.
+ *
+ * The empty `$escape` argument is required for RFC 4180: PHP's historical
+ * default treats a backslash as an escape character, which would swallow
+ * the closing quote of a field ending in one.
+ *
+ * @return list<array<string, string>>
+ */
+ private function parseCsv(string $body): array
+ {
+ $text = $body;
+ if (str_starts_with($text, "\xEF\xBB\xBF")) {
+ $text = substr($text, 3);
+ }
+
+ $stream = fopen('php://temp', 'r+');
+ if ($stream === false) {
+ throw new InvalidResponseException('could not open a stream to parse the CSV');
+ }
+
+ try {
+ fwrite($stream, $text);
+ rewind($stream);
+
+ $header = fgetcsv($stream, null, ',', '"', '');
+ if (!is_array($header) || $header === [null]) {
+ throw new InvalidResponseException(
+ 'the batch response has no CSV header row: '
+ . var_export(substr($body, 0, 120), true),
+ );
+ }
+ $header = array_map(static fn ($name): string => trim((string) $name), $header);
+ $width = count($header);
+
+ $rows = [];
+ while (($record = fgetcsv($stream, null, ',', '"', '')) !== false) {
+ if (!is_array($record) || $record === [null]) {
+ continue;
+ }
+ $record = array_slice(array_pad($record, $width, ''), 0, $width);
+ $rows[] = array_combine(
+ $header,
+ array_map(static fn ($value): string => (string) ($value ?? ''), $record),
+ );
+ }
+
+ return $rows;
+ } finally {
+ fclose($stream);
+ }
+ }
+
+ /**
+ * Decode a JSON object body, or throw {@see InvalidResponseException}.
+ *
+ * @return array<string, mixed>
+ */
+ private function decodeJsonObject(string $body): array
+ {
+ try {
+ $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
+ } catch (\JsonException $exception) {
+ throw new InvalidResponseException(
+ 'response is not valid JSON — something between you and the API is '
+ . 'answering instead of it: ' . var_export(substr($body, 0, 120), true),
+ 0,
+ $exception,
+ );
+ }
+
+ if (!is_array($data) || (array_is_list($data) && $data !== [])) {
+ throw new InvalidResponseException(
+ 'expected a JSON object, got ' . get_debug_type($data),
+ );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Pull `mensagem` out of an error body, falling back to a default.
+ */
+ private static function errorMessage(string $body, string $fallback): string
+ {
+ $data = json_decode($body, true);
+ if (is_array($data) && isset($data['mensagem']) && is_scalar($data['mensagem'])) {
+ $message = (string) $data['mensagem'];
+ if ($message !== '') {
+ return $message;
+ }
+ }
+
+ return $fallback;
+ }
+
+ /**
+ * Parse the `Retry-After` header in both RFC 9110 forms.
+ *
+ * It may arrive as a number of seconds (`"120"`) or as an HTTP date
+ * (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Handling only the first is the
+ * common bug, and it makes a client ignore exactly the wait request that
+ * matters most.
+ */
+ private static function retryAfterSeconds(?string $value): ?float
+ {
+ if ($value === null) {
+ return null;
+ }
+ $value = trim($value);
+ if ($value === '') {
+ return null;
+ }
+ if (is_numeric($value)) {
+ return max(0.0, (float) $value);
+ }
+
+ $target = strtotime($value);
+ if ($target === false) {
+ return null;
+ }
+
+ return max(0.0, (float) $target - microtime(true));
+ }
+
+ /**
+ * How long to wait before the next attempt.
+ *
+ * The server's `Retry-After` wins over the local calculation when present:
+ * it knows when it will be back, we are guessing. It still goes through
+ * the `backoffMax` cap. Otherwise the wait is drawn from `[0, ceiling]`
+ * rather than being the ceiling — see `$backoffBase` on why that matters.
+ */
+ private function backoff(int $attempt, ?float $retryAfter): float
+ {
+ if ($retryAfter !== null) {
+ return min($retryAfter, $this->backoffMax);
+ }
+
+ $ceiling = min($this->backoffBase * (2 ** ($attempt - 1)), $this->backoffMax);
+
+ return (mt_rand() / mt_getrandmax()) * $ceiling;
+ }
+
+ /**
+ * Perform the GET, with retries. Returns `[status, body]`.
+ *
+ * *Definitive* error statuses (400, 404) are returned normally, for the
+ * caller to turn into the right exception — only transient failures become
+ * exceptions here, and only once the attempts are exhausted.
+ *
+ * @param list<int> $acceptedStatus
+ *
+ * @return array{0: int, 1: string}
+ */
+ private function request(string $path, array $acceptedStatus = [200]): array
+ {
+ $url = $this->baseUrl . $path;
+ $headers = [
+ 'User-Agent' => $this->userAgent,
+ 'Accept' => 'application/json, text/csv;q=0.9, */*;q=0.1',
+ 'Accept-Encoding' => 'identity',
+ ];
+ $deadline = $this->totalTimeout === null ? null : self::monotonic() + $this->totalTimeout;
+
+ $lastStatus = null;
+ $lastRetryAfter = null;
+ $lastNetworkError = null;
+
+ for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
+ if ($deadline !== null && self::monotonic() >= $deadline) {
+ break;
+ }
+
+ try {
+ $response = $this->transport->get($url, $headers, $this->timeout);
+
+ if (
+ in_array($response->status, $acceptedStatus, true)
+ || !in_array($response->status, self::RETRIABLE_STATUS, true)
+ ) {
+ return [$response->status, $response->body];
+ }
+
+ $lastStatus = $response->status;
+ $lastRetryAfter = self::retryAfterSeconds($response->header('Retry-After'));
+ $lastNetworkError = null;
+ } catch (NetworkException $exception) {
+ $lastNetworkError = $exception;
+ $lastStatus = null;
+ $lastRetryAfter = null;
+ }
+
+ if ($attempt === $this->maxAttempts) {
+ break;
+ }
+
+ $wait = $this->backoff($attempt, $lastRetryAfter);
+ if ($deadline !== null) {
+ $remaining = $deadline - self::monotonic();
+ if ($remaining <= 0) {
+ break;
+ }
+ $wait = min($wait, $remaining);
+ }
+ if ($wait > 0) {
+ usleep((int) round($wait * 1_000_000));
+ }
+ }
+
+ $this->fail($url, $lastStatus, $lastRetryAfter, $lastNetworkError);
+ }
+
+ /**
+ * Turn the last failure into the exception that says what to do about it.
+ */
+ private function fail(
+ string $url,
+ ?int $status,
+ ?float $retryAfter,
+ ?NetworkException $networkError,
+ ): never {
+ $attempts = sprintf('(after %d attempt(s))', $this->maxAttempts);
+
+ if ($status === 429) {
+ throw new RateLimitedException(
+ sprintf('rate limit reached at %s %s', $url, $attempts),
+ $retryAfter,
+ );
+ }
+ if ($status !== null) {
+ throw new ServerErrorException(
+ sprintf('the API answered %d at %s %s', $status, $url, $attempts),
+ $status,
+ );
+ }
+
+ throw new NetworkException(
+ sprintf(
+ 'could not reach %s %s: %s',
+ $url,
+ $attempts,
+ $networkError?->getMessage() ?? 'the call ran out of time before any attempt',
+ ),
+ 0,
+ $networkError,
+ );
+ }
+
+ private static function monotonic(): float
+ {
+ return hrtime(true) / 1_000_000_000;
+ }
+}
diff --git a/src/CurlTransport.php b/src/CurlTransport.php
new file mode 100644
index 0000000..d22949b
--- /dev/null
+++ b/src/CurlTransport.php
@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The default {@see Transport}, built on ext-curl.
+ *
+ * curl is used directly rather than through an HTTP abstraction: this library
+ * makes one GET, and a wrapper for that would cost dependencies without buying
+ * anything. Hosts that need their own HTTP layer implement {@see Transport}
+ * instead.
+ *
+ * Each call opens its own handle, so one instance is safe to share.
+ */
+final class CurlTransport implements Transport
+{
+ /**
+ * @param bool $verifyTls Whether to verify the peer's certificate. Leave
+ * it alone. It exists for a lab against a self-signed local instance,
+ * and turning it off on the public API removes the only thing making
+ * the answer trustworthy.
+ * @param int $maxRedirects How many redirects to follow.
+ */
+ public function __construct(
+ public readonly bool $verifyTls = true,
+ public readonly int $maxRedirects = 3,
+ ) {
+ }
+
+ public function get(string $url, array $headers, float $timeout): Response
+ {
+ $handle = curl_init();
+ if ($handle === false) {
+ throw new NetworkException('could not initialise a curl handle');
+ }
+
+ $lines = [];
+ foreach ($headers as $name => $value) {
+ $lines[] = $name . ': ' . $value;
+ }
+
+ $milliseconds = max(1, (int) round($timeout * 1000));
+ $received = [];
+
+ curl_setopt_array($handle, [
+ CURLOPT_URL => $url,
+ CURLOPT_HTTPGET => true,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => $lines,
+ CURLOPT_CONNECTTIMEOUT_MS => $milliseconds,
+ CURLOPT_TIMEOUT_MS => $milliseconds,
+ CURLOPT_FOLLOWLOCATION => $this->maxRedirects > 0,
+ CURLOPT_MAXREDIRS => $this->maxRedirects,
+ CURLOPT_SSL_VERIFYPEER => $this->verifyTls,
+ CURLOPT_SSL_VERIFYHOST => $this->verifyTls ? 2 : 0,
+ CURLOPT_NOSIGNAL => true,
+ CURLOPT_HEADERFUNCTION => static function ($_handle, string $line) use (&$received): int {
+ $length = strlen($line);
+ $parts = explode(':', $line, 2);
+ if (count($parts) === 2) {
+ $received[trim($parts[0])][] = trim($parts[1]);
+ }
+
+ return $length;
+ },
+ ]);
+
+ $body = curl_exec($handle);
+ $error = curl_error($handle);
+ $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
+ unset($handle);
+
+ if ($body === false || $status === 0) {
+ throw new NetworkException(sprintf(
+ 'could not reach %s: %s',
+ $url,
+ $error !== '' ? $error : 'the request did not complete',
+ ));
+ }
+
+ return new Response($status, $received, (string) $body);
+ }
+}
diff --git a/src/Geo.php b/src/Geo.php
new file mode 100644
index 0000000..c677622
--- /dev/null
+++ b/src/Geo.php
@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The CEP's coordinate, when the database has one.
+ *
+ * The point is the **median** of the coordinates of that CEP's addresses, not
+ * the mean: one badly geocoded address 40 km away shifts a mean and does not
+ * move a median.
+ *
+ * Always check {@see Geo::$precisao} before using the coordinate for distance:
+ *
+ * - `"logradouro"`: the point is a stretch of street. `raio_m` lands around
+ * 100–150 m. Good for maps and for distance.
+ * - `"localidade"`: the CEP *is* the whole municipality (a city-wide general
+ * CEP), and the point is its centroid. `raio_m` runs from 7 km to 40 km.
+ * Computing shipping from this is wrong by tens of kilometres.
+ *
+ * `raio_m` is the radius containing 90% of that CEP's addresses. It exists
+ * precisely so that error cannot pass unnoticed.
+ */
+final class Geo
+{
+ /**
+ * Property names are the API's own JSON keys, `raio_m` included. See
+ * {@see Address} for why they are not translated or camel-cased.
+ *
+ * @param string $precisao Kept a plain string rather than an enum so a
+ * precision level the API adds later cannot break this client.
+ */
+ public function __construct(
+ public readonly float $lat,
+ public readonly float $lon,
+ public readonly int $raio_m,
+ public readonly string $precisao,
+ ) {
+ }
+
+ /**
+ * Build from the decoded `geo` block, or `null` when it is missing or
+ * malformed.
+ *
+ * A malformed `geo` is not worth losing the whole address over, since the
+ * rest of the response is still useful.
+ */
+ public static function fromArray(mixed $data): ?self
+ {
+ if (!is_array($data)) {
+ return null;
+ }
+ foreach (['lat', 'lon', 'raio_m', 'precisao'] as $key) {
+ if (!isset($data[$key]) || !is_scalar($data[$key])) {
+ return null;
+ }
+ }
+ if (!is_numeric($data['lat']) || !is_numeric($data['lon']) || !is_numeric($data['raio_m'])) {
+ return null;
+ }
+
+ return new self(
+ lat: (float) $data['lat'],
+ lon: (float) $data['lon'],
+ raio_m: (int) (float) $data['raio_m'],
+ precisao: (string) $data['precisao'],
+ );
+ }
+
+ /**
+ * `true` when the coordinate points at a street, not a municipality.
+ *
+ * Shorthand for `precisao === "logradouro"`, which is the check to make
+ * before using the point to measure distance.
+ */
+ public function isPrecise(): bool
+ {
+ return $this->precisao === 'logradouro';
+ }
+
+ /**
+ * @return array{lat: float, lon: float, raio_m: int, precisao: string}
+ */
+ public function toArray(): array
+ {
+ return [
+ 'lat' => $this->lat,
+ 'lon' => $this->lon,
+ 'raio_m' => $this->raio_m,
+ 'precisao' => $this->precisao,
+ ];
+ }
+}
diff --git a/src/Health.php b/src/Health.php
new file mode 100644
index 0000000..08f7547
--- /dev/null
+++ b/src/Health.php
@@ -0,0 +1,76 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The response of `GET /health`.
+ *
+ * Good for two things: checking that the API answers, and knowing **which
+ * version of the database** is live. The DNE database changes every two weeks;
+ * {@see Health::$dne} identifies it.
+ */
+final class Health
+{
+ /**
+ * Property names are the API's own JSON keys, `base_gerada_em` included.
+ * See {@see Address} for why they are not translated or camel-cased.
+ */
+ public function __construct(
+ public readonly string $status,
+ public readonly string $dne,
+ public readonly int $ceps,
+ public readonly int $geo,
+ public readonly string $base_gerada_em,
+ ) {
+ }
+
+ /**
+ * Build from the decoded response.
+ *
+ * @param array<string, mixed> $data
+ */
+ public static function fromArray(array $data): self
+ {
+ return new self(
+ status: self::asString($data['status'] ?? ''),
+ dne: self::asString($data['dne'] ?? ''),
+ ceps: self::asInt($data['ceps'] ?? 0),
+ geo: self::asInt($data['geo'] ?? 0),
+ base_gerada_em: self::asString($data['base_gerada_em'] ?? ''),
+ );
+ }
+
+ /**
+ * `true` when the API reports itself healthy.
+ */
+ public function isOk(): bool
+ {
+ return $this->status === 'ok';
+ }
+
+ /**
+ * @return array<string, mixed>
+ */
+ public function toArray(): array
+ {
+ return [
+ 'status' => $this->status,
+ 'dne' => $this->dne,
+ 'ceps' => $this->ceps,
+ 'geo' => $this->geo,
+ 'base_gerada_em' => $this->base_gerada_em,
+ ];
+ }
+
+ private static function asString(mixed $value): string
+ {
+ return is_scalar($value) ? (string) $value : '';
+ }
+
+ private static function asInt(mixed $value): int
+ {
+ return is_numeric($value) ? (int) (float) $value : 0;
+ }
+}
diff --git a/src/InvalidCepException.php b/src/InvalidCepException.php
new file mode 100644
index 0000000..21a4593
--- /dev/null
+++ b/src/InvalidCepException.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The CEP is not 8 digits.
+ *
+ * Usually thrown **without** a request ever being made — the client checks the
+ * format before going to the network. It is also thrown when the API answers
+ * 400 to a CEP that passed local validation, in which case it carries the
+ * API's own message.
+ *
+ * Extends `\InvalidArgumentException`: this is an argument error.
+ */
+final class InvalidCepException extends \InvalidArgumentException implements CepisException
+{
+}
diff --git a/src/InvalidResponseException.php b/src/InvalidResponseException.php
new file mode 100644
index 0000000..ee77984
--- /dev/null
+++ b/src/InvalidResponseException.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * A success status came back with a body that cannot be parsed.
+ *
+ * In practice this 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.
+ */
+final class InvalidResponseException extends \RuntimeException implements CepisException
+{
+}
diff --git a/src/NetworkException.php b/src/NetworkException.php
new file mode 100644
index 0000000..506b615
--- /dev/null
+++ b/src/NetworkException.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The request never completed: DNS, refused connection, timeout, TLS.
+ *
+ * A {@see Transport} signals a failed request by throwing this. The client
+ * treats it as retriable and only lets it escape once every configured attempt
+ * has failed.
+ */
+final class NetworkException extends \RuntimeException implements CepisException
+{
+}
diff --git a/src/RateLimitedException.php b/src/RateLimitedException.php
new file mode 100644
index 0000000..579fe7b
--- /dev/null
+++ b/src/RateLimitedException.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The API (or the edge in front of it) answered 429 on every attempt.
+ */
+final class RateLimitedException extends \RuntimeException implements CepisException
+{
+ /**
+ * @param float|null $retryAfter What the server asked for, in seconds, or
+ * `null` if it did not say. The client already honoured this across
+ * the attempts it made; it is exposed for callers who want to
+ * reschedule the work.
+ */
+ public function __construct(
+ string $message,
+ public readonly ?float $retryAfter = null,
+ ?\Throwable $previous = null,
+ ) {
+ parent::__construct($message, 0, $previous);
+ }
+}
diff --git a/src/Response.php b/src/Response.php
new file mode 100644
index 0000000..f0ecb8d
--- /dev/null
+++ b/src/Response.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * What a {@see Transport} hands back: an HTTP status, the response headers and
+ * the body as a string.
+ *
+ * Deliberately not a PSR-7 message. This library makes one GET and needs three
+ * things out of it; a plain value object keeps the {@see Transport} interface
+ * implementable in a few lines against whatever HTTP layer the host
+ * application already has.
+ */
+final class Response
+{
+ /**
+ * @param array<string, string|list<string>> $headers Response headers.
+ * Keys may be in any case and values may be a single string or a list
+ * of them; {@see Response::header()} handles both, so an implementer
+ * can pass through whatever their HTTP layer produced.
+ */
+ public function __construct(
+ public readonly int $status,
+ public readonly array $headers = [],
+ public readonly string $body = '',
+ ) {
+ }
+
+ /**
+ * Look a header up case-insensitively, returning the first value or `null`
+ * when it is absent.
+ */
+ public function header(string $name): ?string
+ {
+ foreach ($this->headers as $key => $value) {
+ if (strcasecmp((string) $key, $name) !== 0) {
+ continue;
+ }
+ if (is_array($value)) {
+ $value = $value[array_key_first($value)] ?? null;
+ }
+
+ return is_scalar($value) ? (string) $value : null;
+ }
+
+ return null;
+ }
+}
diff --git a/src/ServerErrorException.php b/src/ServerErrorException.php
new file mode 100644
index 0000000..731b23b
--- /dev/null
+++ b/src/ServerErrorException.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The API answered 5xx on every attempt.
+ */
+final class ServerErrorException extends \RuntimeException implements CepisException
+{
+ /**
+ * @param int $status HTTP status seen on the last attempt.
+ */
+ public function __construct(
+ string $message,
+ public readonly int $status,
+ ?\Throwable $previous = null,
+ ) {
+ parent::__construct($message, 0, $previous);
+ }
+}
diff --git a/src/Transport.php b/src/Transport.php
new file mode 100644
index 0000000..811ac69
--- /dev/null
+++ b/src/Transport.php
@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * How the client actually performs a GET.
+ *
+ * The default is {@see CurlTransport}, and most callers never touch this.
+ * It exists because some hosts require their own HTTP layer: a WordPress
+ * plugin is expected to go through the WP HTTP API (`wp_remote_get`) so that
+ * site owners keep proxy support and the `http_request_args` filters they rely
+ * on, and a library hardcoding curl cannot be a good citizen there.
+ *
+ * One method, no PSR-7, no framework types: an implementation over
+ * `wp_remote_get` is about fifteen lines. See the README for that example.
+ *
+ * Implementations must be safe to reuse across calls; the client holds one for
+ * its lifetime.
+ */
+interface Transport
+{
+ /**
+ * Perform a GET and return the response, whatever its status.
+ *
+ * A non-2xx status is **not** an error here — return it and let the client
+ * decide whether it is retriable. Throw {@see NetworkException} only when
+ * the request did not complete at all: DNS failure, refused connection,
+ * timeout, TLS error.
+ *
+ * Retries, backoff and `Retry-After` are the client's job. An
+ * implementation should make exactly one request and should not retry.
+ *
+ * @param string $url Absolute URL, query string included.
+ * @param array<string, string> $headers Request headers to send, as
+ * name => value.
+ * @param float $timeout Seconds to allow for this single
+ * attempt. Implementations that distinguish connect from read timeouts
+ * should apply it to both.
+ *
+ * @throws NetworkException the request never completed.
+ */
+ public function get(string $url, array $headers, float $timeout): Response;
+}
diff --git a/tests/BatchTest.php b/tests/BatchTest.php
new file mode 100644
index 0000000..95b25ff
--- /dev/null
+++ b/tests/BatchTest.php
@@ -0,0 +1,139 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis\Tests;
+
+final class BatchTest extends ServerTestCase
+{
+ public function testPreservesOrderAndMarksMissing(): void
+ {
+ $this->server->respond('/csv?ceps=01001000%2C99999999%2C20040020', [self::csv([
+ '01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107',
+ '99999999,,,,,,,,,,,,',
+ '20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001',
+ ])]);
+
+ $result = $this->client->lookupMany(['01001000', '99999999', '20040020']);
+
+ self::assertCount(3, $result);
+ self::assertNotNull($result[0]);
+ self::assertSame('São Paulo', $result[0]->localidade);
+ self::assertNull($result[1]);
+ self::assertNotNull($result[2]);
+ self::assertSame('RJ', $result[2]->uf);
+ self::assertSame('Avenida Rio Branco', $result[2]->logradouro);
+ }
+
+ public function testMissingEntryDoesNotTruncateTheArray(): void
+ {
+ $this->server->respond('/csv?ceps=99999999%2C01001000', [self::csv([
+ '99999999,,,,,,,,,,,,',
+ '01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,1,,11,1',
+ ])]);
+
+ $result = $this->client->lookupMany(['99999999', '01001000']);
+
+ self::assertCount(2, $result);
+ self::assertNull($result[0]);
+ self::assertNotNull($result[1]);
+ self::assertSame([0, 1], array_keys($result));
+ }
+
+ public function testBatchCarriesNoCoordinate(): void
+ {
+ $this->server->respond('/csv?ceps=01001000', [self::csv([
+ '01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,1,,11,1',
+ ])]);
+
+ $first = $this->client->lookupMany(['01001000'])[0];
+ self::assertNotNull($first);
+ self::assertNull($first->geo);
+ }
+
+ public function testFieldWithCommaIsParsed(): void
+ {
+ $this->server->respond('/csv?ceps=01001000', [self::csv([
+ '01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1',
+ ])]);
+
+ $first = $this->client->lookupMany(['01001000'])[0];
+ self::assertNotNull($first);
+ self::assertSame('Rua Um, Dois', $first->logradouro);
+ }
+
+ public function testEscapedQuoteIsParsed(): void
+ {
+ $this->server->respond('/csv?ceps=01001000', [self::csv([
+ '01001000,"Rua ""Dom"" Pedro",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1',
+ ])]);
+
+ $first = $this->client->lookupMany(['01001000'])[0];
+ self::assertNotNull($first);
+ self::assertSame('Rua "Dom" Pedro', $first->logradouro);
+ }
+
+ public function testTrailingBackslashDoesNotSwallowTheQuote(): void
+ {
+ $this->server->respond('/csv?ceps=01001000', [self::csv([
+ '01001000,"Rua Barra\\",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1',
+ ])]);
+
+ $first = $this->client->lookupMany(['01001000'])[0];
+ self::assertNotNull($first);
+ self::assertSame('Rua Barra\\', $first->logradouro);
+ self::assertSame('São Paulo', $first->localidade);
+ }
+
+ public function testEmptyListMakesNoRequest(): void
+ {
+ self::assertSame([], $this->client->lookupMany([]));
+ self::assertSame([], $this->server->calls());
+ }
+
+ public function testChunksAboveTheApiLimit(): void
+ {
+ $ceps = [];
+ for ($i = 0; $i < 501; $i++) {
+ $ceps[] = sprintf('%08d', $i);
+ }
+ $this->server->respond('*', [['kind' => 'csv_echo']]);
+
+ $result = $this->client->lookupMany($ceps);
+
+ self::assertCount(501, $result);
+ self::assertSame(2, count($this->server->calls()));
+ self::assertNotNull($result[0]);
+ self::assertSame('00000000', $result[0]->cep);
+ self::assertNotNull($result[500]);
+ self::assertSame('00000500', $result[500]->cep);
+ }
+
+ public function testTruncatedResponseDoesNotShift(): void
+ {
+ $this->server->respond('/csv?ceps=01001000%2C20040020', [self::csv([
+ '01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1',
+ ])]);
+
+ $result = $this->client->lookupMany(['01001000', '20040020']);
+
+ self::assertCount(2, $result);
+ self::assertNotNull($result[0]);
+ self::assertSame('01001000', $result[0]->cep);
+ self::assertNull($result[1]);
+ }
+
+ public function testMalformedCepIsNullNotAnException(): void
+ {
+ $this->server->respond('/csv?ceps=01001000%2Cnot-a-cep', [self::csv([
+ '01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,1,,11,1',
+ 'not-a-cep,,,,,,,,,,,,',
+ ])]);
+
+ $result = $this->client->lookupMany(['01001000', 'not-a-cep']);
+
+ self::assertCount(2, $result);
+ self::assertNotNull($result[0]);
+ self::assertNull($result[1]);
+ }
+}
diff --git a/tests/ConfigurationTest.php b/tests/ConfigurationTest.php
new file mode 100644
index 0000000..158d911
--- /dev/null
+++ b/tests/ConfigurationTest.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis\Tests;
+
+use Cepis\Client;
+use PHPUnit\Framework\TestCase;
+
+final class ConfigurationTest extends TestCase
+{
+ public function testTrailingSlashIsNotDuplicated(): void
+ {
+ self::assertSame('http://x', (new Client(baseUrl: 'http://x/'))->baseUrl);
+ self::assertSame('http://x', (new Client(baseUrl: 'http://x///'))->baseUrl);
+ }
+
+ public function testDefaults(): void
+ {
+ $client = new Client();
+
+ self::assertSame(Client::DEFAULT_URL, $client->baseUrl);
+ self::assertSame(3, $client->maxAttempts);
+ self::assertSame(0.5, $client->backoffBase);
+ self::assertSame(8.0, $client->backoffMax);
+ self::assertNull($client->totalTimeout);
+ }
+
+ public function testUserAgentIdentifiesTheLibrary(): void
+ {
+ self::assertStringContainsString(
+ 'cepis-php/' . Client::VERSION,
+ (new Client())->userAgent,
+ );
+ self::assertSame('myapp/1.0', (new Client(userAgent: 'myapp/1.0'))->userAgent);
+ }
+
+ public function testInvalidParameters(): void
+ {
+ foreach ([['maxAttempts' => 0], ['timeout' => 0.0], ['backoffBase' => -1.0]] as $bad) {
+ try {
+ new Client(...$bad);
+ self::fail('expected InvalidArgumentException for ' . json_encode($bad));
+ } catch (\InvalidArgumentException) {
+ self::assertTrue(true);
+ }
+ }
+ }
+
+ public function testDefaultClientIsShared(): void
+ {
+ self::assertSame(Client::default(), Client::default());
+ self::assertSame(Client::DEFAULT_URL, Client::default()->baseUrl);
+ }
+
+ public function testMaxBatchMatchesTheApiLimit(): void
+ {
+ self::assertSame(500, Client::MAX_BATCH);
+ }
+}
diff --git a/tests/HealthTest.php b/tests/HealthTest.php
new file mode 100644
index 0000000..08c047b
--- /dev/null
+++ b/tests/HealthTest.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis\Tests;
+
+final class HealthTest extends ServerTestCase
+{
+ public function testReadsHealth(): void
+ {
+ $this->server->respond('/health', [self::json([
+ 'status' => 'ok',
+ 'dne' => '26071',
+ 'ceps' => 1596168,
+ 'geo' => 894489,
+ 'base_gerada_em' => '2026-07-24T03:48:28Z',
+ ])]);
+
+ $health = $this->client->health();
+
+ self::assertTrue($health->isOk());
+ self::assertSame('26071', $health->dne);
+ self::assertSame(1596168, $health->ceps);
+ self::assertSame(894489, $health->geo);
+ self::assertSame('2026-07-24T03:48:28Z', $health->base_gerada_em);
+ }
+
+ public function testDegradedDoesNotThrow(): void
+ {
+ $this->server->respond('/health', [self::json(
+ ['status' => 'degradado', 'dne' => '', 'ceps' => 0, 'geo' => 0],
+ 503,
+ )]);
+
+ $health = $this->client->health();
+
+ self::assertFalse($health->isOk());
+ self::assertSame('degradado', $health->status);
+ self::assertSame(1, $this->server->countCalls('/health'));
+ }
+}
diff --git a/tests/LookupTest.php b/tests/LookupTest.php
new file mode 100644
index 0000000..9483948
--- /dev/null
+++ b/tests/LookupTest.php
@@ -0,0 +1,154 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis\Tests;
+
+use Cepis\CepNotFoundException;
+use Cepis\InvalidCepException;
+use Cepis\InvalidResponseException;
+
+final class LookupTest extends ServerTestCase
+{
+ public function testSuccessfulLookup(): void
+ {
+ $this->server->respond('/01001000', [self::json(self::SE_ADDRESS)]);
+ $address = $this->client->lookup('01001-000');
+
+ self::assertSame('Praça da Sé', $address->logradouro);
+ self::assertSame('São Paulo', $address->localidade);
+ self::assertSame('SP', $address->uf);
+ self::assertSame('01001000', $address->cep);
+ self::assertSame('01001-000', $address->formattedCep());
+ self::assertNull($address->geo);
+ }
+
+ public function testReadsTheCoordinateWhenPresent(): void
+ {
+ $body = self::SE_ADDRESS + [];
+ $body['geo'] = ['lat' => -23.567202, 'lon' => -46.649659, 'raio_m' => 1084, 'precisao' => 'logradouro'];
+ $this->server->respond('/01310100', [self::json($body)]);
+ $address = $this->client->lookup('01310100');
+
+ self::assertNotNull($address->geo);
+ self::assertEqualsWithDelta(-23.567202, $address->geo->lat, 1e-9);
+ self::assertEqualsWithDelta(-46.649659, $address->geo->lon, 1e-9);
+ self::assertSame(1084, $address->geo->raio_m);
+ self::assertSame('logradouro', $address->geo->precisao);
+ self::assertTrue($address->geo->isPrecise());
+ }
+
+ public function testMunicipalityCoordinateIsNotPrecise(): void
+ {
+ $body = self::SE_ADDRESS;
+ $body['geo'] = ['lat' => -23.5, 'lon' => -46.6, 'raio_m' => 22000, 'precisao' => 'localidade'];
+ $this->server->respond('/01001000', [self::json($body)]);
+
+ $geo = $this->client->lookup('01001000')->geo;
+ self::assertNotNull($geo);
+ self::assertFalse($geo->isPrecise());
+ }
+
+ public function testMalformedGeoDoesNotLoseTheAddress(): void
+ {
+ $body = self::SE_ADDRESS;
+ $body['geo'] = ['lat' => 'not a number'];
+ $this->server->respond('/01001000', [self::json($body)]);
+ $address = $this->client->lookup('01001000');
+
+ self::assertNull($address->geo);
+ self::assertSame('São Paulo', $address->localidade);
+ }
+
+ public function testNewApiFieldDoesNotBreak(): void
+ {
+ $body = self::SE_ADDRESS;
+ $body['campo_do_futuro'] = 'value';
+ $this->server->respond('/01001000', [self::json($body)]);
+ $address = $this->client->lookup('01001000');
+
+ self::assertSame('value', $address->raw['campo_do_futuro']);
+ self::assertSame('value', $address->field('campo_do_futuro'));
+ self::assertNull($address->field('nao_existe'));
+ self::assertSame('São Paulo', $address->localidade);
+ }
+
+ public function testMissingFieldsBecomeEmptyStringsNotNull(): void
+ {
+ $this->server->respond('/01001000', [self::json(['cep' => '01001000'])]);
+ $address = $this->client->lookup('01001000');
+
+ self::assertSame('', $address->logradouro);
+ self::assertSame('', $address->uf);
+ }
+
+ public function testNotFound(): void
+ {
+ $this->server->respond('/99999999', [self::json(
+ ['erro' => 'cep_nao_encontrado', 'mensagem' => 'CEP não existe na base do DNE'],
+ 404,
+ )]);
+
+ try {
+ $this->client->lookup('99999999');
+ self::fail('expected CepNotFoundException');
+ } catch (CepNotFoundException $exception) {
+ self::assertStringContainsString('99999999', $exception->getMessage());
+ }
+ }
+
+ public function testNotFoundIsNotRetried(): void
+ {
+ $this->server->respond('/99999999', [self::json(['erro' => 'cep_nao_encontrado'], 404)]);
+
+ $this->expectException(CepNotFoundException::class);
+
+ try {
+ $this->client->lookup('99999999');
+ } finally {
+ self::assertSame(1, $this->server->countCalls('/99999999'));
+ }
+ }
+
+ public function testInvalidCepNeverLeavesTheMachine(): void
+ {
+ try {
+ $this->client->lookup('123');
+ self::fail('expected InvalidCepException');
+ } catch (InvalidCepException) {
+ self::assertSame([], $this->server->calls());
+ }
+ }
+
+ public function testApi400BecomesInvalidCep(): void
+ {
+ $this->server->respond('/00000000', [self::json(
+ ['erro' => 'cep_invalido', 'mensagem' => 'rejected by the API'],
+ 400,
+ )]);
+
+ try {
+ $this->client->lookup('00000000');
+ self::fail('expected InvalidCepException');
+ } catch (InvalidCepException $exception) {
+ self::assertSame('rejected by the API', $exception->getMessage());
+ self::assertSame(1, $this->server->countCalls('/00000000'));
+ }
+ }
+
+ public function testResponseThatIsNotJson(): void
+ {
+ $this->server->respond('/01001000', [self::raw(200, '<html>captive portal</html>')]);
+
+ $this->expectException(InvalidResponseException::class);
+ $this->client->lookup('01001000');
+ }
+
+ public function testJsonArrayIsNotAnObject(): void
+ {
+ $this->server->respond('/01001000', [self::raw(200, '[1, 2, 3]')]);
+
+ $this->expectException(InvalidResponseException::class);
+ $this->client->lookup('01001000');
+ }
+}
diff --git a/tests/NormalizationTest.php b/tests/NormalizationTest.php
new file mode 100644
index 0000000..36de024
--- /dev/null
+++ b/tests/NormalizationTest.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis\Tests;
+
+use Cepis\CepisException;
+use Cepis\Client;
+use Cepis\InvalidCepException;
+use PHPUnit\Framework\TestCase;
+
+final class NormalizationTest extends TestCase
+{
+ public function testAcceptsMaskedAndUnmasked(): void
+ {
+ foreach (['01001000', '01001-000', ' 01001-000 ', '01001.000'] as $value) {
+ self::assertSame('01001000', Client::normalizeCep($value));
+ }
+ }
+
+ public function testRejectsBadFormat(): void
+ {
+ foreach (['', '123', '0100100a', '010010000', 'abcdefgh', '01001-0000'] as $value) {
+ try {
+ Client::normalizeCep($value);
+ self::fail(sprintf('expected "%s" to be rejected', $value));
+ } catch (InvalidCepException $exception) {
+ self::assertStringContainsString('8 digits', $exception->getMessage());
+ }
+ }
+ }
+
+ public function testOneCatchHandlesEverything(): void
+ {
+ $this->expectException(CepisException::class);
+ Client::normalizeCep('123');
+ }
+
+ public function testInvalidCepIsAlsoAnInvalidArgumentException(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ Client::normalizeCep('123');
+ }
+}
diff --git a/tests/RecordingTransport.php b/tests/RecordingTransport.php
new file mode 100644
index 0000000..da93fb3
--- /dev/null
+++ b/tests/RecordingTransport.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis\Tests;
+
+use Cepis\NetworkException;
+use Cepis\Response;
+use Cepis\Transport;
+
+/**
+ * Records what the client asked for and replays a canned list of responses.
+ *
+ * Used only to test the seam itself — what the client passes an implementer,
+ * and how it reacts to what one returns. Every test of the client's own
+ * behaviour goes over a real socket to {@see TestServer}.
+ */
+final class RecordingTransport implements Transport
+{
+ /** @var list<array{url: string, headers: array<string, string>, timeout: float}> */
+ public array $calls = [];
+
+ /** @param list<Response> $responses */
+ public function __construct(private array $responses = [])
+ {
+ }
+
+ public function get(string $url, array $headers, float $timeout): Response
+ {
+ $this->calls[] = ['url' => $url, 'headers' => $headers, 'timeout' => $timeout];
+
+ $response = array_shift($this->responses);
+ if ($response === null) {
+ throw new NetworkException('no response left for ' . $url);
+ }
+
+ return $response;
+ }
+}
diff --git a/tests/RetryTest.php b/tests/RetryTest.php
new file mode 100644
index 0000000..1b922b6
--- /dev/null
+++ b/tests/RetryTest.php
@@ -0,0 +1,127 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis\Tests;
+
+use Cepis\Client;
+use Cepis\NetworkException;
+use Cepis\RateLimitedException;
+use Cepis\ServerErrorException;
+
+final class RetryTest extends ServerTestCase
+{
+ public function testRetriesOn500ThenSucceeds(): void
+ {
+ $this->server->respond('/01001000', [
+ self::json(['erro' => 'interno'], 500),
+ self::json(self::SE_ADDRESS),
+ ]);
+
+ self::assertSame('São Paulo', $this->client->lookup('01001000')->localidade);
+ self::assertSame(2, $this->server->countCalls('/01001000'));
+ }
+
+ public function testExhaustsAttemptsThenThrows(): void
+ {
+ $this->server->respond('/01001000', [self::json(['erro' => 'interno'], 503)]);
+
+ try {
+ $this->client->lookup('01001000');
+ self::fail('expected ServerErrorException');
+ } catch (ServerErrorException $exception) {
+ self::assertSame(503, $exception->status);
+ self::assertSame(3, $this->server->countCalls('/01001000'));
+ }
+ }
+
+ public function testMaxAttemptsOneDisablesRetry(): void
+ {
+ $client = new Client(baseUrl: $this->server->url, timeout: 5.0, maxAttempts: 1);
+ $this->server->respond('/01001000', [self::raw(500, '{}')]);
+
+ try {
+ $client->lookup('01001000');
+ self::fail('expected ServerErrorException');
+ } catch (ServerErrorException) {
+ self::assertSame(1, $this->server->countCalls('/01001000'));
+ }
+ }
+
+ public function test429BecomesRateLimitedWithRetryAfter(): void
+ {
+ $this->server->respond('/01001000', [self::raw(429, '{}', ['Retry-After' => '7'])]);
+
+ try {
+ $this->client->lookup('01001000');
+ self::fail('expected RateLimitedException');
+ } catch (RateLimitedException $exception) {
+ self::assertNotNull($exception->retryAfter);
+ self::assertEqualsWithDelta(7.0, $exception->retryAfter, 1e-9);
+ }
+ }
+
+ public function testRetryAfterAcceptsAnHttpDate(): void
+ {
+ $date = gmdate('D, d M Y H:i:s \G\M\T', time() + 4);
+ $this->server->respond('/01001000', [self::raw(429, '{}', ['Retry-After' => $date])]);
+
+ try {
+ $this->client->lookup('01001000');
+ self::fail('expected RateLimitedException');
+ } catch (RateLimitedException $exception) {
+ self::assertNotNull($exception->retryAfter);
+ self::assertGreaterThan(0.0, $exception->retryAfter);
+ self::assertLessThanOrEqual(5.0, $exception->retryAfter);
+ }
+ }
+
+ public function testRetryAfterHonoursTheCap(): void
+ {
+ $this->server->respond('/01001000', [self::raw(429, '{}', ['Retry-After' => '3600'])]);
+ $started = self::monotonic();
+
+ try {
+ $this->client->lookup('01001000');
+ self::fail('expected RateLimitedException');
+ } catch (RateLimitedException) {
+ self::assertLessThan(5.0, self::monotonic() - $started);
+ }
+ }
+
+ public function testNetworkErrorWhenNothingListens(): void
+ {
+ $client = new Client(
+ baseUrl: 'http://127.0.0.1:1',
+ timeout: 1.0,
+ maxAttempts: 2,
+ backoffBase: 0.001,
+ backoffMax: 0.01,
+ );
+
+ $this->expectException(NetworkException::class);
+ $client->lookup('01001000');
+ }
+
+ public function testTotalTimeoutBoundsTheWholeCall(): void
+ {
+ $client = new Client(
+ baseUrl: $this->server->url,
+ timeout: 5.0,
+ maxAttempts: 50,
+ backoffBase: 0.05,
+ backoffMax: 0.05,
+ totalTimeout: 0.3,
+ );
+ $this->server->respond('/01001000', [self::raw(500, '{}')]);
+ $started = self::monotonic();
+
+ try {
+ $client->lookup('01001000');
+ self::fail('expected ServerErrorException');
+ } catch (ServerErrorException) {
+ self::assertLessThan(5.0, self::monotonic() - $started);
+ self::assertLessThan(50, $this->server->countCalls('/01001000'));
+ }
+ }
+}
diff --git a/tests/ServerTestCase.php b/tests/ServerTestCase.php
new file mode 100644
index 0000000..d000778
--- /dev/null
+++ b/tests/ServerTestCase.php
@@ -0,0 +1,106 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis\Tests;
+
+use Cepis\Client;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * A fake server and a client whose backoff is near zero.
+ *
+ * The backoff is tiny because what is under test is the retry logic, not
+ * `usleep`'s ability to sleep.
+ */
+abstract class ServerTestCase extends TestCase
+{
+ /**
+ * A CEP as the API returns it, used as the fixture across the suite. The
+ * field names stay Portuguese because they mirror the API's JSON.
+ */
+ protected const SE_ADDRESS = [
+ 'cep' => '01001000',
+ 'logradouro' => 'Praça da Sé',
+ 'complemento' => 'lado ímpar',
+ 'unidade' => '',
+ 'bairro' => 'Sé',
+ 'localidade' => 'São Paulo',
+ 'uf' => 'SP',
+ 'estado' => 'São Paulo',
+ 'regiao' => 'Sudeste',
+ 'ibge' => '3550308',
+ 'gia' => '1004',
+ 'ddd' => '11',
+ 'siafi' => '7107',
+ ];
+
+ protected const CSV_HEADER = 'cep,logradouro,complemento,unidade,bairro,localidade,uf,'
+ . 'estado,regiao,ibge,gia,ddd,siafi';
+
+ protected TestServer $server;
+
+ protected Client $client;
+
+ protected function setUp(): void
+ {
+ $this->server = new TestServer();
+ $this->client = new Client(
+ baseUrl: $this->server->url,
+ timeout: 5.0,
+ backoffBase: 0.001,
+ backoffMax: 0.01,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ $this->server->stop();
+ }
+
+ /**
+ * @param array<string, mixed> $body
+ * @param array<string, string> $headers
+ *
+ * @return array<string, mixed>
+ */
+ protected static function json(array $body, int $status = 200, array $headers = []): array
+ {
+ return [
+ 'status' => $status,
+ 'body' => json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
+ 'headers' => $headers,
+ 'content_type' => 'application/json; charset=utf-8',
+ ];
+ }
+
+ /**
+ * @param array<string, string> $headers
+ *
+ * @return array<string, mixed>
+ */
+ protected static function raw(int $status, string $body, array $headers = []): array
+ {
+ return ['status' => $status, 'body' => $body, 'headers' => $headers];
+ }
+
+ /**
+ * @param list<string> $rows
+ *
+ * @return array<string, mixed>
+ */
+ protected static function csv(array $rows): array
+ {
+ return [
+ 'status' => 200,
+ 'body' => self::CSV_HEADER . "\n" . implode("\n", $rows) . "\n",
+ 'headers' => [],
+ 'content_type' => 'text/csv; charset=utf-8',
+ ];
+ }
+
+ protected static function monotonic(): float
+ {
+ return hrtime(true) / 1_000_000_000;
+ }
+}
diff --git a/tests/StreamTransport.php b/tests/StreamTransport.php
new file mode 100644
index 0000000..174e516
--- /dev/null
+++ b/tests/StreamTransport.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis\Tests;
+
+use Cepis\NetworkException;
+use Cepis\Response;
+use Cepis\Transport;
+
+/**
+ * A {@see Transport} over PHP streams rather than curl — roughly the shape a
+ * WordPress plugin's `wp_remote_get` implementation takes, and proof that the
+ * seam is implementable against a different HTTP stack in a few lines.
+ */
+final class StreamTransport implements Transport
+{
+ public function get(string $url, array $headers, float $timeout): Response
+ {
+ $lines = '';
+ foreach ($headers as $name => $value) {
+ $lines .= $name . ': ' . $value . "\r\n";
+ }
+
+ $context = stream_context_create(['http' => [
+ 'method' => 'GET',
+ 'header' => $lines,
+ 'timeout' => $timeout,
+ 'ignore_errors' => true,
+ ]]);
+
+ $body = @file_get_contents($url, false, $context);
+ if ($body === false) {
+ throw new NetworkException('could not reach ' . $url);
+ }
+
+ $status = 0;
+ $received = [];
+ foreach ($http_response_header ?? [] as $line) {
+ if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $match) === 1) {
+ $status = (int) $match[1];
+ continue;
+ }
+ $parts = explode(':', $line, 2);
+ if (count($parts) === 2) {
+ $received[trim($parts[0])][] = trim($parts[1]);
+ }
+ }
+
+ return new Response($status, $received, $body);
+ }
+}
diff --git a/tests/TestServer.php b/tests/TestServer.php
new file mode 100644
index 0000000..b0b384f
--- /dev/null
+++ b/tests/TestServer.php
@@ -0,0 +1,148 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis\Tests;
+
+/**
+ * A throwaway HTTP server on a random loopback port, run with PHP's built-in
+ * server.
+ *
+ * The suite points a real {@see \Cepis\Client} at this over a real socket. No
+ * test stubs the transport to exercise the client, so the code path under test
+ * is the one that runs in production, request construction and error body
+ * reading included. It doubles as the test that `baseUrl` works: if it did
+ * not, none of the suite would run.
+ *
+ * The server process and the test process share state through two files, since
+ * they are separate processes: `routes.json`, which the test writes, and
+ * `calls.log`, which the server appends to.
+ */
+final class TestServer
+{
+ public readonly string $url;
+
+ /** @var resource */
+ private $process;
+
+ private string $stateDir;
+
+ public function __construct()
+ {
+ $this->stateDir = sys_get_temp_dir() . '/cepis-test-' . bin2hex(random_bytes(8));
+ if (!mkdir($this->stateDir, 0o700, true) && !is_dir($this->stateDir)) {
+ throw new \RuntimeException('could not create ' . $this->stateDir);
+ }
+ file_put_contents($this->stateDir . '/routes.json', '{}');
+ file_put_contents($this->stateDir . '/calls.log', '');
+
+ $port = self::freePort();
+ $this->url = 'http://127.0.0.1:' . $port;
+
+ $process = proc_open(
+ [PHP_BINARY, '-S', '127.0.0.1:' . $port, __DIR__ . '/server/router.php'],
+ [
+ 0 => ['pipe', 'r'],
+ 1 => ['file', $this->stateDir . '/server.log', 'a'],
+ 2 => ['file', $this->stateDir . '/server.log', 'a'],
+ ],
+ $pipes,
+ __DIR__ . '/server',
+ ['CEPIS_TEST_STATE' => $this->stateDir, 'PATH' => getenv('PATH') ?: '/usr/bin'],
+ );
+ if (!is_resource($process)) {
+ throw new \RuntimeException('could not start the built-in server');
+ }
+ $this->process = $process;
+ foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) {
+ fclose($pipe);
+ }
+ }
+
+ $this->waitUntilListening($port);
+ }
+
+ /**
+ * Register the responses for a request URI.
+ *
+ * Each request consumes the next entry, which is how retrying is tested;
+ * the last entry repeats once the list runs out. A route registered under
+ * `"*"` answers any URI that has none of its own.
+ *
+ * @param list<array<string, mixed>> $responses
+ */
+ public function respond(string $uri, array $responses): void
+ {
+ $routes = json_decode((string) file_get_contents($this->stateDir . '/routes.json'), true);
+ $routes = is_array($routes) ? $routes : [];
+ $routes[$uri] = $responses;
+ file_put_contents(
+ $this->stateDir . '/routes.json',
+ json_encode($routes, JSON_THROW_ON_ERROR),
+ );
+ }
+
+ /**
+ * Every request URI the server has seen, in order.
+ *
+ * @return list<string>
+ */
+ public function calls(): array
+ {
+ $raw = (string) file_get_contents($this->stateDir . '/calls.log');
+
+ return array_values(array_filter(explode("\n", $raw), 'strlen'));
+ }
+
+ /**
+ * How many requests so far began with `$prefix`.
+ */
+ public function countCalls(string $prefix): int
+ {
+ return count(array_filter(
+ $this->calls(),
+ static fn (string $uri): bool => str_starts_with($uri, $prefix),
+ ));
+ }
+
+ public function stop(): void
+ {
+ if (is_resource($this->process)) {
+ proc_terminate($this->process);
+ proc_close($this->process);
+ }
+ foreach (['routes.json', 'calls.log', 'server.log'] as $name) {
+ @unlink($this->stateDir . '/' . $name);
+ }
+ @rmdir($this->stateDir);
+ }
+
+ private function waitUntilListening(int $port): void
+ {
+ $deadline = microtime(true) + 15.0;
+ while (microtime(true) < $deadline) {
+ $socket = @fsockopen('127.0.0.1', $port, $code, $message, 0.2);
+ if (is_resource($socket)) {
+ fclose($socket);
+
+ return;
+ }
+ usleep(20_000);
+ }
+
+ throw new \RuntimeException('the built-in server never started listening on ' . $port);
+ }
+
+ private static function freePort(): int
+ {
+ $socket = stream_socket_server('tcp://127.0.0.1:0', $code, $message);
+ if ($socket === false) {
+ throw new \RuntimeException('could not bind a loopback socket: ' . $message);
+ }
+ $name = (string) stream_socket_get_name($socket, false);
+ fclose($socket);
+
+ return (int) substr($name, (int) strrpos($name, ':') + 1);
+ }
+}
diff --git a/tests/TransportTest.php b/tests/TransportTest.php
new file mode 100644
index 0000000..da467a1
--- /dev/null
+++ b/tests/TransportTest.php
@@ -0,0 +1,141 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis\Tests;
+
+use Cepis\CepNotFoundException;
+use Cepis\Client;
+use Cepis\CurlTransport;
+use Cepis\NetworkException;
+use Cepis\RateLimitedException;
+use Cepis\Response;
+use Cepis\ServerErrorException;
+
+/**
+ * The transport seam is the extension point a WordPress plugin uses to route
+ * requests through `wp_remote_get`, so it gets tested the same way the rest
+ * does: a real implementation against the real test server.
+ */
+final class TransportTest extends ServerTestCase
+{
+ public function testDefaultTransportIsCurl(): void
+ {
+ self::assertInstanceOf(CurlTransport::class, (new Client())->transport);
+ }
+
+ public function testAnAlternativeTransportIsUsedEndToEnd(): void
+ {
+ $client = new Client(baseUrl: $this->server->url, transport: new StreamTransport());
+ $this->server->respond('/01001000', [self::json(self::SE_ADDRESS)]);
+
+ $address = $client->lookup('01001000');
+
+ self::assertSame('São Paulo', $address->localidade);
+ self::assertSame(1, $this->server->countCalls('/01001000'));
+ }
+
+ public function testTheClientSendsItsIdentifyingHeaders(): void
+ {
+ $transport = new RecordingTransport([new Response(200, [], '{"cep":"01001000"}')]);
+ $client = new Client(baseUrl: 'https://example.invalid', timeout: 3.5, transport: $transport);
+
+ $client->lookup('01001000');
+
+ self::assertSame('https://example.invalid/01001000', $transport->calls[0]['url']);
+ self::assertStringContainsString('cepis-php/', $transport->calls[0]['headers']['User-Agent']);
+ self::assertArrayHasKey('Accept', $transport->calls[0]['headers']);
+ self::assertSame(3.5, $transport->calls[0]['timeout']);
+ }
+
+ public function testATransportThrowingNetworkExceptionIsRetried(): void
+ {
+ $transport = new RecordingTransport([]);
+ $client = new Client(
+ baseUrl: 'https://example.invalid',
+ maxAttempts: 3,
+ backoffBase: 0.001,
+ backoffMax: 0.01,
+ transport: $transport,
+ );
+
+ try {
+ $client->lookup('01001000');
+ self::fail('expected NetworkException');
+ } catch (NetworkException) {
+ self::assertCount(3, $transport->calls);
+ }
+ }
+
+ public function testATransportReturningA503IsRetriedNotThrown(): void
+ {
+ $transport = new RecordingTransport([
+ new Response(503, [], '{}'),
+ new Response(503, [], '{}'),
+ new Response(200, [], json_encode(self::SE_ADDRESS, JSON_THROW_ON_ERROR)),
+ ]);
+ $client = new Client(
+ baseUrl: 'https://example.invalid',
+ backoffBase: 0.001,
+ backoffMax: 0.01,
+ transport: $transport,
+ );
+
+ self::assertSame('São Paulo', $client->lookup('01001000')->localidade);
+ self::assertCount(3, $transport->calls);
+ }
+
+ public function testA404FromATransportIsNotRetried(): void
+ {
+ $transport = new RecordingTransport([new Response(404, [], '{}')]);
+ $client = new Client(baseUrl: 'https://example.invalid', transport: $transport);
+
+ try {
+ $client->lookup('99999999');
+ self::fail('expected CepNotFoundException');
+ } catch (CepNotFoundException) {
+ self::assertCount(1, $transport->calls);
+ }
+ }
+
+ public function testRetryAfterIsReadFromATransportSuppliedHeader(): void
+ {
+ $transport = new RecordingTransport([new Response(429, ['retry-after' => ['5']], '{}')]);
+ $client = new Client(
+ baseUrl: 'https://example.invalid',
+ maxAttempts: 1,
+ transport: $transport,
+ );
+
+ try {
+ $client->lookup('01001000');
+ self::fail('expected RateLimitedException');
+ } catch (RateLimitedException $exception) {
+ self::assertEqualsWithDelta(5.0, $exception->retryAfter, 1e-9);
+ }
+ }
+
+ public function testResponseHeaderLookupIsCaseInsensitiveAndTakesListValues(): void
+ {
+ $response = new Response(200, ['Retry-After' => '7', 'X-Multi' => ['a', 'b']], '');
+
+ self::assertSame('7', $response->header('retry-after'));
+ self::assertSame('7', $response->header('RETRY-AFTER'));
+ self::assertSame('a', $response->header('x-multi'));
+ self::assertNull($response->header('absent'));
+ }
+
+ public function testServerErrorStillSurfacesThroughAnAlternativeTransport(): void
+ {
+ $client = new Client(
+ baseUrl: $this->server->url,
+ backoffBase: 0.001,
+ backoffMax: 0.01,
+ transport: new StreamTransport(),
+ );
+ $this->server->respond('/01001000', [self::raw(500, '{}')]);
+
+ $this->expectException(ServerErrorException::class);
+ $client->lookup('01001000');
+ }
+}
diff --git a/tests/server/router.php b/tests/server/router.php
new file mode 100644
index 0000000..e082027
--- /dev/null
+++ b/tests/server/router.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+$state = getenv('CEPIS_TEST_STATE');
+if ($state === false) {
+ http_response_code(500);
+ echo 'CEPIS_TEST_STATE is not set';
+
+ return true;
+}
+
+$uri = $_SERVER['REQUEST_URI'] ?? '/';
+file_put_contents($state . '/calls.log', $uri . "\n", FILE_APPEND | LOCK_EX);
+
+$routes = json_decode((string) file_get_contents($state . '/routes.json'), true);
+$routes = is_array($routes) ? $routes : [];
+
+$specs = $routes[$uri] ?? $routes['*'] ?? null;
+if (!is_array($specs) || $specs === []) {
+ header('Content-Type: application/json; charset=utf-8');
+ http_response_code(404);
+ echo json_encode(['erro' => 'cep_nao_encontrado', 'mensagem' => 'no route registered']);
+
+ return true;
+}
+
+$calls = array_filter(explode("\n", (string) file_get_contents($state . '/calls.log')), 'strlen');
+$seen = count(array_filter($calls, static fn (string $line): bool => $line === $uri));
+$spec = $specs[min(max($seen - 1, 0), count($specs) - 1)];
+
+if (($spec['kind'] ?? 'static') === 'csv_echo') {
+ parse_str((string) parse_url($uri, PHP_URL_QUERY), $query);
+ $ceps = array_filter(explode(',', (string) ($query['ceps'] ?? '')), 'strlen');
+ $rows = ['cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi'];
+ foreach ($ceps as $cep) {
+ $rows[] = $cep . ',Rua Teste,,,Centro,Cidade,SP,São Paulo,Sudeste,1,,11,1';
+ }
+ header('Content-Type: text/csv; charset=utf-8');
+ http_response_code(200);
+ echo implode("\n", $rows) . "\n";
+
+ return true;
+}
+
+foreach (($spec['headers'] ?? []) as $name => $value) {
+ header($name . ': ' . $value);
+}
+header('Content-Type: ' . ($spec['content_type'] ?? 'text/plain; charset=utf-8'));
+http_response_code((int) ($spec['status'] ?? 200));
+echo (string) ($spec['body'] ?? '');
+
+return true;