From 67276c6444a70b61030ab3ee52b84dc5014af0e8 Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Mon, 27 Jul 2026 20:31:30 -0300 Subject: Carry the municipality of a not-found CEP on the exception 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. --- src/CepNotFoundException.php | 19 ++++++ src/Client.php | 36 ++++++++++-- src/Faixa.php | 137 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 src/Faixa.php (limited to 'src') 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( @@ -389,6 +399,24 @@ final class Client return $fallback; } + /** + * 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. * 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 @@ +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; + } +} -- cgit v1.3