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