diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/cepis/Address.php | 133 | ||||
| -rw-r--r-- | lib/cepis/CepNotFoundException.php | 15 | ||||
| -rw-r--r-- | lib/cepis/CepisException.php | 34 | ||||
| -rw-r--r-- | lib/cepis/Client.php | 549 | ||||
| -rw-r--r-- | lib/cepis/CurlTransport.php | 85 | ||||
| -rw-r--r-- | lib/cepis/Geo.php | 94 | ||||
| -rw-r--r-- | lib/cepis/Health.php | 76 | ||||
| -rw-r--r-- | lib/cepis/InvalidCepException.php | 19 | ||||
| -rw-r--r-- | lib/cepis/InvalidResponseException.php | 16 | ||||
| -rw-r--r-- | lib/cepis/NetworkException.php | 16 | ||||
| -rw-r--r-- | lib/cepis/RateLimitedException.php | 25 | ||||
| -rw-r--r-- | lib/cepis/Response.php | 50 | ||||
| -rw-r--r-- | lib/cepis/ServerErrorException.php | 22 | ||||
| -rw-r--r-- | lib/cepis/Transport.php | 45 |
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; +} |