summaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 16:41:48 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 16:47:14 -0300
commit535dbda6b48171f8115b455e54790de94d97d42a (patch)
tree5c0410011e61a883bdf063ee612875a637fb1d22 /lib
downloadwoocommerce-0.1.0.tar.gz
Initial importv0.1.0
Diffstat (limited to 'lib')
-rw-r--r--lib/cepis/Address.php133
-rw-r--r--lib/cepis/CepNotFoundException.php15
-rw-r--r--lib/cepis/CepisException.php34
-rw-r--r--lib/cepis/Client.php549
-rw-r--r--lib/cepis/CurlTransport.php85
-rw-r--r--lib/cepis/Geo.php94
-rw-r--r--lib/cepis/Health.php76
-rw-r--r--lib/cepis/InvalidCepException.php19
-rw-r--r--lib/cepis/InvalidResponseException.php16
-rw-r--r--lib/cepis/NetworkException.php16
-rw-r--r--lib/cepis/RateLimitedException.php25
-rw-r--r--lib/cepis/Response.php50
-rw-r--r--lib/cepis/ServerErrorException.php22
-rw-r--r--lib/cepis/Transport.php45
14 files changed, 1179 insertions, 0 deletions
diff --git a/lib/cepis/Address.php b/lib/cepis/Address.php
new file mode 100644
index 0000000..8b970aa
--- /dev/null
+++ b/lib/cepis/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/lib/cepis/CepNotFoundException.php b/lib/cepis/CepNotFoundException.php
new file mode 100644
index 0000000..c64de8b
--- /dev/null
+++ b/lib/cepis/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/lib/cepis/CepisException.php b/lib/cepis/CepisException.php
new file mode 100644
index 0000000..efd1a86
--- /dev/null
+++ b/lib/cepis/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/lib/cepis/Client.php b/lib/cepis/Client.php
new file mode 100644
index 0000000..4e24b7f
--- /dev/null
+++ b/lib/cepis/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/lib/cepis/CurlTransport.php b/lib/cepis/CurlTransport.php
new file mode 100644
index 0000000..d22949b
--- /dev/null
+++ b/lib/cepis/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/lib/cepis/Geo.php b/lib/cepis/Geo.php
new file mode 100644
index 0000000..c677622
--- /dev/null
+++ b/lib/cepis/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/lib/cepis/Health.php b/lib/cepis/Health.php
new file mode 100644
index 0000000..08f7547
--- /dev/null
+++ b/lib/cepis/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/lib/cepis/InvalidCepException.php b/lib/cepis/InvalidCepException.php
new file mode 100644
index 0000000..21a4593
--- /dev/null
+++ b/lib/cepis/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/lib/cepis/InvalidResponseException.php b/lib/cepis/InvalidResponseException.php
new file mode 100644
index 0000000..ee77984
--- /dev/null
+++ b/lib/cepis/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/lib/cepis/NetworkException.php b/lib/cepis/NetworkException.php
new file mode 100644
index 0000000..506b615
--- /dev/null
+++ b/lib/cepis/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/lib/cepis/RateLimitedException.php b/lib/cepis/RateLimitedException.php
new file mode 100644
index 0000000..579fe7b
--- /dev/null
+++ b/lib/cepis/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/lib/cepis/Response.php b/lib/cepis/Response.php
new file mode 100644
index 0000000..f0ecb8d
--- /dev/null
+++ b/lib/cepis/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/lib/cepis/ServerErrorException.php b/lib/cepis/ServerErrorException.php
new file mode 100644
index 0000000..731b23b
--- /dev/null
+++ b/lib/cepis/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/lib/cepis/Transport.php b/lib/cepis/Transport.php
new file mode 100644
index 0000000..811ac69
--- /dev/null
+++ b/lib/cepis/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;
+}