summaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:25:04 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:53:07 -0300
commit82d92ed4291663bae94014b4363064dbb26c6a51 (patch)
tree69d91a59c955f1e6ec6fc0007324b5d73b3f0d01 /lib
parent8e719fc715eb7ae9aa1c842e633f15b5c6cd3688 (diff)
downloaddart-main.tar.gz
Name the municipality behind a CEP that does not existHEADv0.2.0main
A CEP that fails a lookup is rarely a random number: it is usually someone who knows the city and guessed the digits, and the classic guess is the -000 that 957 municipalities never received. The API now attaches a faixa block to the 404 naming the municipality that band belongs to, so the caller can answer "no, but you were looking at Areal/RJ" instead of just "no". It stays an error. Faixa hangs off CepNotFoundException, never off Address, and cepIni/cepFim are the extremes that actually exist in the municipality rather than the range the Correios publish — echoing back the published start would return the very CEP that just failed. The interval is sparse, which is what ceps is for; it is not a list of alternatives. Parsing is deliberately forgiving: a missing, empty or malformed block is null. Losing a clean not-found to a parse error over the hint attached to it would be a bad trade.
Diffstat (limited to 'lib')
-rw-r--r--lib/cepis.dart2
-rw-r--r--lib/src/client.dart25
-rw-r--r--lib/src/errors.dart34
-rw-r--r--lib/src/models.dart129
-rw-r--r--lib/src/version.dart2
5 files changed, 185 insertions, 7 deletions
diff --git a/lib/cepis.dart b/lib/cepis.dart
index 2bc42b8..4a1a2ab 100644
--- a/lib/cepis.dart
+++ b/lib/cepis.dart
@@ -30,7 +30,7 @@ export 'src/errors.dart'
NetworkException,
RateLimitedException,
ServerException;
-export 'src/models.dart' show Address, Geo, Health;
+export 'src/models.dart' show Address, Faixa, Geo, Health;
final Client _default = Client();
diff --git a/lib/src/client.dart b/lib/src/client.dart
index 9ddc165..dc0ddb8 100644
--- a/lib/src/client.dart
+++ b/lib/src/client.dart
@@ -186,6 +186,10 @@ class Client {
/// own message: the format already passed local validation, so the server
/// knows something this client does not.
///
+ /// A 404 may carry a [Faixa] on [CepNotFoundException.faixa], naming the
+ /// municipality the CEP would have belonged to. It is context for the error,
+ /// not an address, and it is `null` whenever the API did not send one.
+ ///
/// Throws [InvalidCepException] when the CEP is not 8 digits — no request is
/// made; [CepNotFoundException] when it is well-formed but absent from the
/// database; [RateLimitedException] on 429 after every attempt;
@@ -200,6 +204,7 @@ class Client {
throw CepNotFoundException(
'CEP $number does not exist in the DNE database',
cep: number,
+ faixa: _faixa(reply.body),
);
}
if (reply.status == 400) {
@@ -458,6 +463,26 @@ String _errorMessage(String body, String fallback) {
return fallback;
}
+/// Reads the `faixa` block out of a 404 body, or returns `null`.
+///
+/// Every failure — a body that is not JSON, no `faixa` key, a `faixa` that is
+/// not an object, one that is empty — is the same `null`. The 404 is the
+/// answer the caller asked for; it must not be lost to a parse error over the
+/// hint hanging off it.
+Faixa? _faixa(String body) {
+ final Object? data;
+ try {
+ data = jsonDecode(body);
+ } on FormatException {
+ return null;
+ }
+ if (data is! Map<String, dynamic>) {
+ return null;
+ }
+ final block = data['faixa'];
+ return block is Map<String, dynamic> ? Faixa.fromJson(block) : null;
+}
+
Duration? _retryAfter(String? value) {
if (value == null) {
return null;
diff --git a/lib/src/errors.dart b/lib/src/errors.dart
index 24b35c4..be32b25 100644
--- a/lib/src/errors.dart
+++ b/lib/src/errors.dart
@@ -18,6 +18,8 @@
/// all, when the CEP is malformed before it ever leaves the machine.
library;
+import 'models.dart';
+
/// Base of every error this library throws. Catch this to handle any failure.
///
/// Sealed: the subtypes below are the complete set, so a `switch` on one is
@@ -59,12 +61,40 @@ final class InvalidCepException extends CepisException
}
/// The CEP is well-formed but does not exist in the DNE database.
+///
+/// [faixa] may name the municipality the CEP would have belonged to. It does
+/// not soften the error: this is still a 404, and there is still no address.
final class CepNotFoundException extends CepisException {
- /// Creates the error for [message], recording the normalised [cep].
- const CepNotFoundException(super.message, {this.cep});
+ /// Creates the error for [message], recording the normalised [cep] and the
+ /// [faixa] the API named, when it named one.
+ const CepNotFoundException(super.message, {this.cep, this.faixa});
/// The CEP that was looked up, normalised to 8 digits.
final String? cep;
+
+ /// The municipality that owns the numeric band this CEP fell in, or `null`.
+ ///
+ /// `null` when the CEP falls in a band no municipality owns — the API
+ /// **omits** the block there rather than sending null — and also whenever the
+ /// block cannot be read. See [Faixa], and read [Faixa.cepIni]/[Faixa.cepFim]
+ /// as the extremes that exist, not as a range of valid CEPs.
+ final Faixa? faixa;
+
+ /// Mentions the municipality when the API named one.
+ ///
+ /// The count comes along deliberately: `118 CEPs` across nearly five
+ /// thousand numbers is what stops the two bounds from reading like a range
+ /// where anything in between would work.
+ @override
+ String toString() {
+ final band = faixa;
+ if (band == null) {
+ return super.toString();
+ }
+ return '$runtimeType: $message — that band belongs to '
+ '${band.localidade}/${band.uf}, which has ${band.ceps} CEPs scattered '
+ 'between ${band.cepIni} and ${band.cepFim}';
+ }
}
/// The request never completed: DNS, refused connection, timeout, TLS.
diff --git a/lib/src/models.dart b/lib/src/models.dart
index 57a008f..378ebb3 100644
--- a/lib/src/models.dart
+++ b/lib/src/models.dart
@@ -12,9 +12,10 @@
/// come from the Correios database.
///
/// Only the *casing* changes, where Dart demands it: the API's `raio_m` is
-/// [Geo.raioM] and its `base_gerada_em` is [Health.baseGeradaEm]. Every other
-/// field is a single lowercase word and is spelled exactly as the API spells
-/// it.
+/// [Geo.raioM], its `cep_ini` and `cep_fim` are [Faixa.cepIni] and
+/// [Faixa.cepFim], and its `base_gerada_em` is [Health.baseGeradaEm]. Every
+/// other field is a single lowercase word and is spelled exactly as the API
+/// spells it.
library;
/// The CEP's coordinate, when the database has one.
@@ -263,6 +264,128 @@ class Address {
'localidade: $localidade, uf: $uf, geo: $geo)';
}
+/// The municipality a CEP that does not exist *would* belong to.
+///
+/// This is what the API sends in the `faixa` block of a **404**, and it arrives
+/// on `CepNotFoundException.faixa`. It is context for an error, never an
+/// address: the CEP asked for still does not exist, and nothing here says
+/// otherwise.
+///
+/// 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. Naming the municipality turns "no"
+/// into "no, but you were looking at Areal/RJ".
+///
+/// [cepIni] and [cepFim] are the smallest and the largest CEP that **actually
+/// exist** in that municipality — not the bounds the Correios publish. Areal's
+/// published range starts at `25845000`, which is exactly the CEP that just
+/// failed; echoing it back would say nothing.
+///
+/// **The range is sparse.** Areal's [ceps] is 118, spread over the ~9000
+/// numbers between [cepIni] and [cepFim]. The pair is not a list of valid CEPs
+/// and iterating it would be wrong; [ceps] is there so the emptiness is
+/// visible.
+class Faixa {
+ /// Creates a municipality band from its parts.
+ const Faixa({
+ required this.localidade,
+ required this.uf,
+ required this.estado,
+ required this.regiao,
+ required this.ibge,
+ required this.cepIni,
+ required this.cepFim,
+ required this.ceps,
+ });
+
+ /// Municipality that owns the band the CEP fell in.
+ final String localidade;
+
+ /// Two-letter state code, such as `'RJ'`.
+ final String uf;
+
+ /// State name in full.
+ final String estado;
+
+ /// Region of the country, such as `'Sudeste'`.
+ final String regiao;
+
+ /// IBGE municipality code — the key to join against public census data.
+ final String ibge;
+
+ /// Smallest CEP that exists in the municipality, 8 digits.
+ ///
+ /// The API spells this field `cep_ini`; Dart's naming rules make it `cepIni`
+ /// here. It is an observed minimum, not the Correios' published start.
+ final String cepIni;
+
+ /// Largest CEP that exists in the municipality, 8 digits.
+ ///
+ /// The API spells this field `cep_fim`; Dart's naming rules make it `cepFim`
+ /// here. It is an observed maximum, not the Correios' published end.
+ final String cepFim;
+
+ /// How many CEPs exist between [cepIni] and [cepFim], inclusive.
+ ///
+ /// Almost always far fewer than the arithmetic distance between the two: the
+ /// band is sparse, and this is the number that says how sparse.
+ final int ceps;
+
+ /// Builds a band from the API's `faixa` block, or returns `null`.
+ ///
+ /// A missing or malformed block yields `null` rather than throwing. The 404
+ /// is the answer; losing it to a parse error over the hint attached to it
+ /// would be a bad trade.
+ ///
+ /// `localidade` is the gate: without a municipality name there is nothing to
+ /// tell the caller, so an empty one is the same as no block at all.
+ static Faixa? fromJson(Map<String, dynamic> data) {
+ final localidade = _text(data['localidade']);
+ if (localidade.isEmpty) {
+ return null;
+ }
+ return Faixa(
+ localidade: localidade,
+ uf: _text(data['uf']),
+ estado: _text(data['estado']),
+ regiao: _text(data['regiao']),
+ ibge: _text(data['ibge']),
+ cepIni: _text(data['cep_ini']),
+ cepFim: _text(data['cep_fim']),
+ ceps: _toInt(data['ceps']) ?? 0,
+ );
+ }
+
+ @override
+ bool operator ==(Object other) =>
+ other is Faixa &&
+ other.localidade == localidade &&
+ other.uf == uf &&
+ other.estado == estado &&
+ other.regiao == regiao &&
+ other.ibge == ibge &&
+ other.cepIni == cepIni &&
+ other.cepFim == cepFim &&
+ other.ceps == ceps;
+
+ @override
+ int get hashCode => Object.hash(
+ localidade,
+ uf,
+ estado,
+ regiao,
+ ibge,
+ cepIni,
+ cepFim,
+ ceps,
+ );
+
+ @override
+ String toString() =>
+ 'Faixa(localidade: $localidade, uf: $uf, cepIni: $cepIni, '
+ 'cepFim: $cepFim, ceps: $ceps)';
+}
+
/// The response of `GET /health`.
///
/// Good for two things: checking that the API answers, and knowing **which
diff --git a/lib/src/version.dart b/lib/src/version.dart
index a77195d..8471211 100644
--- a/lib/src/version.dart
+++ b/lib/src/version.dart
@@ -2,4 +2,4 @@
library;
/// Version of this package, as it appears in the default `User-Agent`.
-const String packageVersion = '0.1.0';
+const String packageVersion = '0.2.0';