diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:25:04 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:53:07 -0300 |
| commit | 82d92ed4291663bae94014b4363064dbb26c6a51 (patch) | |
| tree | 69d91a59c955f1e6ec6fc0007324b5d73b3f0d01 /lib | |
| parent | 8e719fc715eb7ae9aa1c842e633f15b5c6cd3688 (diff) | |
| download | dart-3f63360ff982ef2a932eca814fdd74d4cb4b1de9.tar.gz | |
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.dart | 2 | ||||
| -rw-r--r-- | lib/src/client.dart | 25 | ||||
| -rw-r--r-- | lib/src/errors.dart | 34 | ||||
| -rw-r--r-- | lib/src/models.dart | 129 | ||||
| -rw-r--r-- | lib/src/version.dart | 2 |
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'; |