diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:31:30 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:31:30 -0300 |
| commit | 67276c6444a70b61030ab3ee52b84dc5014af0e8 (patch) | |
| tree | ac0246ed287c6aec57e335403b20b23214ec4eb0 | |
| parent | 64764fb9fac0227f8e0443e7c7519d73aec275b9 (diff) | |
| download | php-0.2.0.tar.gz | |
The API now sends an optional faixa block on the 404 of GET /{cep}, naming
the municipality the CEP would have belonged to. That failing CEP is rarely
random: it is usually someone who knows the city and guessed the number,
most often the -000 that 957 municipalities never received. Knowing the
lookup failed "in Areal/RJ" is the difference between a message a user can
act on and a bare "CEP not found".
It stays an error. Faixa hangs off CepNotFoundException, never off Address,
and parsing is deliberately forgiving: a missing, empty or malformed block
yields null rather than turning a clean not-found into a parse failure.
Its cep_ini/cep_fim are the CEPs that exist, not the bounds the Correios
publish, and the interval between them is sparse -- hence ceps, and hence
the docblocks insisting the pair is not a list of valid CEPs.
| -rw-r--r-- | README.md | 56 | ||||
| -rw-r--r-- | src/CepNotFoundException.php | 19 | ||||
| -rw-r--r-- | src/Client.php | 36 | ||||
| -rw-r--r-- | src/Faixa.php | 137 | ||||
| -rw-r--r-- | tests/LookupTest.php | 139 |
5 files changed, 382 insertions, 5 deletions
@@ -233,7 +233,7 @@ the HTTP status that caused it: | exception | when | does retrying help? | | --- | --- | --- | | `InvalidCepException` | not 8 digits | no | -| `CepNotFoundException` | well-formed, not in the database | no | +| `CepNotFoundException` | well-formed, not in the database; may carry `faixa` | no | | `RateLimitedException` | 429; carries `retryAfter` | yes, later | | `ServerErrorException` | 5xx; carries `status` | yes, later | | `NetworkException` | DNS, connection, timeout, TLS | yes, later | @@ -264,6 +264,60 @@ an `\InvalidArgumentException` and `CepNotFoundException` is an 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. +### A CEP that does not exist, but in a city that does + +A failing CEP is rarely random: it is usually someone who knows the city and +guessed the number — and the classic guess is the `-000`, which 957 +municipalities never received. So `CepNotFoundException` carries a `Faixa` when +the API can say which municipality the CEP *would* have belonged to: + +```php +try { + $address = Client::default()->lookup('25845-000'); +} catch (CepNotFoundException $e) { + if ($e->faixa !== null) { + printf( + "CEP not found in %s — the city has %d CEPs, between %s and %s\n", + $e->faixa->label(), // "Areal/RJ" + $e->faixa->ceps, // 118 + $e->faixa->cep_ini, // "25845001" + $e->faixa->cep_fim, // "25849899" + ); + } +} +``` + +It is still a 404. **`faixa` is context for an error, never an address**: a CEP +that has one is just as absent from the database as one that does not, and +nothing here should turn a not-found into a success. What it buys you is a +message worth reading — *"we could not find that CEP in Areal/RJ"* instead of +*"CEP not found"*. + +`$e->faixa` is null whenever the API sent nothing, which is what happens for a +CEP in a band no municipality owns. The field is **omitted, never null**, so +absence is normal; and a `faixa` this client cannot read is also null, because a +malformed block must never cost you the exception you came for. + +| field | | +| --- | --- | +| `localidade`, `uf`, `estado`, `regiao`, `ibge` | the municipality, same keys as on `Address` | +| `cep_ini`, `cep_fim` | the smallest and largest CEP that **exist** there | +| `ceps` | how many CEPs the municipality actually has | +| `label()` | `"Areal/RJ"`, for showing to a person | + +Two things about the range, both easy to get wrong: + +`cep_ini` and `cep_fim` are **not** the bounds the Correios publish. They are +the extremes of what is really in the database. The DNE's own start for Areal is +`25845000` — exactly the CEP that failed above — and it is not `cep_ini` +precisely because it does not exist. + +**The range is sparse.** Areal's 118 CEPs are spread across the ~4,900 numbers +between `25845001` and `25849899`; almost all of that interval is empty. That is +what `ceps` is for. Never iterate the interval, never present the pair as the +list of valid CEPs for the city, and never conclude from +`cep_ini <= $x <= cep_fim` that `$x` exists. + ## Development Everything runs through `make`: diff --git a/src/CepNotFoundException.php b/src/CepNotFoundException.php index c64de8b..e9511e7 100644 --- a/src/CepNotFoundException.php +++ b/src/CepNotFoundException.php @@ -9,7 +9,26 @@ namespace Cepis; * * Extends `\OutOfBoundsException`, the SPL type for a value that is not a * valid key: looking a CEP up is conceptually a key lookup. + * + * {@see CepNotFoundException::$faixa} may name the municipality the CEP would + * have belonged to. It does not soften the answer — the CEP still does not + * exist — it only makes the failure sayable: *"we could not find that CEP in + * Areal/RJ"* rather than *"CEP not found"*. */ final class CepNotFoundException extends \OutOfBoundsException implements CepisException { + /** + * @param Faixa|null $faixa The municipality whose range covers the CEP, or + * `null` when the API sent none — which is the case for a CEP in a band + * no municipality owns, and also whenever the block cannot be read. + * Never assume it is there. See {@see Faixa} for why the range it + * carries is not a list of valid CEPs. + */ + public function __construct( + string $message, + public readonly ?Faixa $faixa = null, + ?\Throwable $previous = null, + ) { + parent::__construct($message, 0, $previous); + } } diff --git a/src/Client.php b/src/Client.php index 4e24b7f..5fd19b1 100644 --- a/src/Client.php +++ b/src/Client.php @@ -30,7 +30,7 @@ namespace Cepis; */ final class Client { - public const VERSION = '0.1.0'; + public const VERSION = '0.2.0'; /** * Where the API lives, unless `baseUrl` says otherwise. @@ -171,6 +171,12 @@ final class Client * the API's own message: the format already passed local validation, so * the server knows something this client does not. * + * A 404 is surfaced as {@see CepNotFoundException}, which may carry a + * {@see Faixa} naming the municipality the CEP would have belonged to. It + * is still a 404 either way: the `faixa` is context for the error, never an + * address, and a CEP that has one is just as absent from the DNE as one + * that 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. @@ -184,9 +190,13 @@ final class Client [$status, $body] = $this->request('/' . $number); if ($status === 404) { - throw new CepNotFoundException( - sprintf('CEP %s does not exist in the DNE database', $number), - ); + $faixa = self::errorFaixa($body); + $message = sprintf('CEP %s does not exist in the DNE database', $number); + if ($faixa !== null) { + $message .= sprintf(' (the range covering it is %s)', $faixa->label()); + } + + throw new CepNotFoundException($message, $faixa); } if ($status === 400) { throw new InvalidCepException( @@ -390,6 +400,24 @@ final class Client } /** + * Pull the optional `faixa` block out of a 404 body, or `null`. + * + * Nothing in here throws, by design. The body of a not-found is extra + * information on top of an answer the caller already has, so a truncated + * response, an HTML error page from a proxy or a `faixa` in a shape this + * release does not know must all end the same way: no `faixa`, and the same + * {@see CepNotFoundException} that would have been thrown anyway. Note the + * plain `json_decode` — {@see Client::decodeJsonObject()} is deliberately + * not used here, because its whole job is to throw. + */ + private static function errorFaixa(string $body): ?Faixa + { + $data = json_decode($body, true); + + return is_array($data) ? Faixa::fromArray($data['faixa'] ?? null) : null; + } + + /** * Parse the `Retry-After` header in both RFC 9110 forms. * * It may arrive as a number of seconds (`"120"`) or as an HTTP date diff --git a/src/Faixa.php b/src/Faixa.php new file mode 100644 index 0000000..3f4be84 --- /dev/null +++ b/src/Faixa.php @@ -0,0 +1,137 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * The municipality a CEP *would* belong to, when the CEP itself does not exist. + * + * It arrives on the 404 of `GET /{cep}` and is carried by + * {@see CepNotFoundException::$faixa}. **It is context for an error, never an + * address.** A CEP with a `faixa` is still a CEP that is not in the DNE: the + * answer to the lookup remains "does not exist". + * + * It exists because a failing CEP is rarely random. It is usually someone who + * knows the city and guessed the number — and the classic guess is the `-000`, + * which 957 municipalities never received. Knowing that `25845000` "is Areal" + * is what lets you say *"we could not find that CEP in Areal/RJ"* instead of + * the useless *"CEP not found"*. + * + * {@see Faixa::$cep_ini} and {@see Faixa::$cep_fim} are the smallest and the + * largest CEP that **actually exist** in that municipality, not the bounds the + * Correios publish for it. The DNE's own start for Areal is `25845000` — + * exactly the CEP that just failed — and it is not `cep_ini` precisely because + * it does not exist. + * + * **The range is sparse, and the pair is not a list of valid CEPs.** Areal's + * 118 CEPs are spread across the ~4,900 numbers between `25845001` and + * `25849899`; the overwhelming majority of that interval is empty. That is what + * {@see Faixa::$ceps} is for. Never iterate the interval, never present it as + * "the CEPs of this city", and never conclude from `cep_ini <= x <= cep_fim` + * that `x` exists. + * + * The whole object is **omitted** by the API — never sent as null — when the + * CEP falls in a band no municipality owns, so absence is normal and + * {@see CepNotFoundException::$faixa} is null for it. + */ +final class Faixa +{ + /** + * Property names are the API's own JSON keys, `cep_ini` and `cep_fim` + * included. See {@see Address} for why they are not translated or + * camel-cased. + * + * @param string $cep_ini Smallest CEP that exists in the municipality, not + * the start the Correios publish for it. + * @param string $cep_fim Largest CEP that exists in the municipality. + * @param int $ceps How many CEPs the municipality actually has. Read it + * together with the pair above: the interval is sparse, and this is the + * only number that says how sparse. + */ + public function __construct( + public readonly string $localidade, + public readonly string $uf = '', + public readonly string $estado = '', + public readonly string $regiao = '', + public readonly string $ibge = '', + public readonly string $cep_ini = '', + public readonly string $cep_fim = '', + public readonly int $ceps = 0, + ) { + } + + /** + * Build from the decoded `faixa` block, or `null` when it is missing, + * empty or malformed. + * + * Deliberately forgiving. A `faixa` this client cannot read must never cost + * the caller the answer it came for: the CEP does not exist either way, and + * turning a clean not-found into a parse failure would trade a useful + * exception for a useless one. Only {@see Faixa::$localidade} is required — + * without a municipality name there is nothing to tell the user — and every + * other field falls back the way {@see Address} does, to an empty string or + * to zero. + */ + public static function fromArray(mixed $data): ?self + { + if (!is_array($data) || $data === []) { + return null; + } + + $localidade = self::asString($data['localidade'] ?? null); + if ($localidade === '') { + return null; + } + + return new self( + localidade: $localidade, + uf: self::asString($data['uf'] ?? null), + estado: self::asString($data['estado'] ?? null), + regiao: self::asString($data['regiao'] ?? null), + ibge: self::asString($data['ibge'] ?? null), + cep_ini: self::asString($data['cep_ini'] ?? null), + cep_fim: self::asString($data['cep_fim'] ?? null), + ceps: self::asInt($data['ceps'] ?? null), + ); + } + + /** + * The municipality as you would show it to a person: `"Areal/RJ"`, or just + * `"Areal"` when the API sent no UF. + */ + public function label(): string + { + return $this->uf === '' ? $this->localidade : $this->localidade . '/' . $this->uf; + } + + /** + * @return array{ + * localidade: string, uf: string, estado: string, regiao: string, + * ibge: string, cep_ini: string, cep_fim: string, ceps: int + * } + */ + public function toArray(): array + { + return [ + 'localidade' => $this->localidade, + 'uf' => $this->uf, + 'estado' => $this->estado, + 'regiao' => $this->regiao, + 'ibge' => $this->ibge, + 'cep_ini' => $this->cep_ini, + 'cep_fim' => $this->cep_fim, + 'ceps' => $this->ceps, + ]; + } + + 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/tests/LookupTest.php b/tests/LookupTest.php index 9483948..03dee57 100644 --- a/tests/LookupTest.php +++ b/tests/LookupTest.php @@ -10,6 +10,23 @@ use Cepis\InvalidResponseException; final class LookupTest extends ServerTestCase { + /** + * The `faixa` the API sends on the 404 of a CEP that does not exist but + * falls inside a municipality's range. `25845000` is the textbook case: it + * is the DNE's own start for Areal, it is the number everyone guesses, and + * it was never issued — which is why `cep_ini` is `25845001`. + */ + private const AREAL_FAIXA = [ + 'localidade' => 'Areal', + 'uf' => 'RJ', + 'estado' => 'Rio de Janeiro', + 'regiao' => 'Sudeste', + 'ibge' => '3300225', + 'cep_ini' => '25845001', + 'cep_fim' => '25849899', + 'ceps' => 118, + ]; + public function testSuccessfulLookup(): void { $this->server->respond('/01001000', [self::json(self::SE_ADDRESS)]); @@ -94,6 +111,128 @@ final class LookupTest extends ServerTestCase self::fail('expected CepNotFoundException'); } catch (CepNotFoundException $exception) { self::assertStringContainsString('99999999', $exception->getMessage()); + self::assertNull($exception->faixa); + } + } + + public function testNotFoundCarriesTheFaixaWhenTheApiSendsOne(): void + { + $this->server->respond('/25845000', [self::json( + [ + 'erro' => 'cep_nao_encontrado', + 'mensagem' => 'CEP não existe na base do DNE', + 'faixa' => self::AREAL_FAIXA, + ], + 404, + )]); + + try { + $this->client->lookup('25845-000'); + self::fail('expected CepNotFoundException'); + } catch (CepNotFoundException $exception) { + $faixa = $exception->faixa; + self::assertNotNull($faixa); + self::assertSame('Areal', $faixa->localidade); + self::assertSame('RJ', $faixa->uf); + self::assertSame('Rio de Janeiro', $faixa->estado); + self::assertSame('Sudeste', $faixa->regiao); + self::assertSame('3300225', $faixa->ibge); + self::assertSame(118, $faixa->ceps); + self::assertSame('Areal/RJ', $faixa->label()); + + // The bounds are the CEPs that exist, not the ones the Correios + // publish: the CEP that just failed is the DNE's own start for + // Areal and is below cep_ini for exactly that reason. + self::assertSame('25845001', $faixa->cep_ini); + self::assertSame('25849899', $faixa->cep_fim); + self::assertTrue($faixa->cep_ini > '25845000'); + + // 118 CEPs over ~4,900 numbers: the pair is a range, never a list. + self::assertLessThan( + (int) $faixa->cep_fim - (int) $faixa->cep_ini, + $faixa->ceps, + ); + + self::assertStringContainsString('Areal/RJ', $exception->getMessage()); + self::assertStringContainsString('25845000', $exception->getMessage()); + } + } + + public function testFaixaIsStillANotFoundAndNotAnAddress(): void + { + $this->server->respond('/25845000', [self::json( + ['erro' => 'cep_nao_encontrado', 'faixa' => self::AREAL_FAIXA], + 404, + )]); + + $this->expectException(CepNotFoundException::class); + $this->client->lookup('25845000'); + } + + /** + * A `faixa` this client cannot read costs nothing: the answer is the same + * not-found it would have been without one. Anything else would trade a + * useful exception for an unhelpful parse failure. + */ + public function testUnreadableFaixaStillGivesACleanNotFound(): void + { + $shapes = [ + 'null' => null, + 'empty object' => [], + 'a string' => 'Areal', + 'a number' => 42, + 'a list' => ['Areal', 'RJ'], + 'no municipality' => ['uf' => 'RJ', 'cep_ini' => '25845001'], + 'empty municipality' => ['localidade' => '', 'uf' => 'RJ'], + 'nested junk' => ['localidade' => ['Areal'], 'ceps' => 'many'], + ]; + + $specs = []; + foreach ($shapes as $faixa) { + $specs[] = self::json(['erro' => 'cep_nao_encontrado', 'faixa' => $faixa], 404); + } + $this->server->respond('/25845000', $specs); + + foreach (array_keys($shapes) as $name) { + try { + $this->client->lookup('25845000'); + self::fail('expected CepNotFoundException for ' . $name); + } catch (CepNotFoundException $exception) { + self::assertNull($exception->faixa, $name); + self::assertStringContainsString('25845000', $exception->getMessage(), $name); + } + } + } + + public function testPartialFaixaKeepsWhatItCanRead(): void + { + $this->server->respond('/25845000', [self::json( + ['erro' => 'cep_nao_encontrado', 'faixa' => ['localidade' => 'Areal', 'ceps' => 'many']], + 404, + )]); + + try { + $this->client->lookup('25845000'); + self::fail('expected CepNotFoundException'); + } catch (CepNotFoundException $exception) { + self::assertNotNull($exception->faixa); + self::assertSame('Areal', $exception->faixa->localidade); + self::assertSame('', $exception->faixa->uf); + self::assertSame('', $exception->faixa->cep_ini); + self::assertSame(0, $exception->faixa->ceps); + self::assertSame('Areal', $exception->faixa->label()); + } + } + + public function testUnreadableNotFoundBodyStillGivesACleanNotFound(): void + { + $this->server->respond('/25845000', [self::raw(404, '<html>proxy says no</html>')]); + + try { + $this->client->lookup('25845000'); + self::fail('expected CepNotFoundException'); + } catch (CepNotFoundException $exception) { + self::assertNull($exception->faixa); } } |