summaryrefslogtreecommitdiffstats
path: root/lib/cepis/Client.php
diff options
context:
space:
mode:
Diffstat (limited to 'lib/cepis/Client.php')
-rw-r--r--lib/cepis/Client.php549
1 files changed, 549 insertions, 0 deletions
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;
+ }
+}