From 64764fb9fac0227f8e0443e7c7519d73aec275b9 Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 14:58:55 -0300 Subject: Initial import --- src/Client.php | 549 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 549 insertions(+) create mode 100644 src/Client.php (limited to 'src/Client.php') 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 @@ +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 $ceps + * + * @return list + */ + 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 $ceps + * + * @return list + */ + 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> + */ + 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 + */ + 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 $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; + } +} -- cgit v1.3