summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md12
-rw-r--r--README.md42
-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
-rw-r--r--pubspec.yaml2
-rw-r--r--test/cepis_test.dart166
9 files changed, 395 insertions, 19 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f9e0cb..df0c793 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
# Changelog
+## 0.2.0
+
+- `CepNotFoundException` now carries `faixa`: the municipality the CEP would
+ have belonged to, when the API names one. Still a 404 — it is context for the
+ error, not an address.
+- New `Faixa` model. Its `cepIni`/`cepFim` are the smallest and the largest CEP
+ that *exist* in the municipality, not the Correios' published bounds, and the
+ interval between them is sparse — read `ceps` for how sparse.
+- `CepNotFoundException.toString` mentions the municipality when there is one.
+- A missing, empty or malformed `faixa` yields `null` and never turns a
+ not-found into a parse failure.
+
## 0.1.0
- First release: `lookup`, `lookupMany`, `health` and `normalizeCep`, with
diff --git a/README.md b/README.md
index ffb4cac..1f4518a 100644
--- a/README.md
+++ b/README.md
@@ -204,6 +204,48 @@ try {
`RateLimitedException` exposes `retryAfter`, `ServerException` exposes `status`,
and `NetworkException` exposes `cause`.
+### When the CEP does not exist
+
+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` may carry a `faixa`
+naming the municipality the CEP *would* have belonged to:
+
+```dart
+try {
+ final address = await cepis.lookup('25845-000');
+} on cepis.CepNotFoundException catch (e) {
+ final faixa = e.faixa;
+ if (faixa != null) {
+ faixa.localidade; // 'Areal'
+ faixa.uf; // 'RJ'
+ faixa.ibge; // '3300225'
+ faixa.cepIni; // '25845001' (the API's `cep_ini`)
+ faixa.cepFim; // '25849899' (the API's `cep_fim`)
+ faixa.ceps; // 118
+ }
+}
+```
+
+Three things this is not:
+
+- **Not an address.** The response is still a 404 and the CEP still does not
+ exist. `faixa` is context for the error — enough to say "did you mean somewhere
+ in Areal/RJ?" — and never a value to save as the user's address.
+- **Not the Correios' published range.** `cepIni` and `cepFim` are the smallest
+ and the largest CEP that *actually exist* in the municipality. The DNE's own
+ start for Areal is `25845000` — exactly the CEP that just failed, which is why
+ echoing it back would be useless.
+- **Not a range of valid CEPs.** It is sparse: Areal's 118 CEPs are scattered
+ over the ~9000 numbers between the two bounds. That is what `ceps` is for. Do
+ not iterate the interval and do not offer it as a list of alternatives.
+
+`faixa` is `null` when the CEP falls in a numeric band no municipality owns — the
+API **omits** the block there rather than sending null — and also whenever the
+block cannot be read. A malformed one never costs you the clean not-found.
+
+`e.toString()` mentions the municipality when there is one.
+
`InvalidCepException` also implements `FormatException`, so code already
handling malformed input during validation keeps working without knowing this
library exists.
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';
diff --git a/pubspec.yaml b/pubspec.yaml
index 68d10c7..e4219dc 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -2,7 +2,7 @@ name: cepis
description: >-
Dart client for the public CEP API at cep.is. Official Correios data (DNE),
refreshed every two weeks. Pure Dart, so it works from Flutter too.
-version: 0.1.0
+version: 0.2.0
repository: https://git.cep.is/dart
issue_tracker: https://git.cep.is/dart/issues
diff --git a/test/cepis_test.dart b/test/cepis_test.dart
index 54df88f..e09601f 100644
--- a/test/cepis_test.dart
+++ b/test/cepis_test.dart
@@ -23,6 +23,23 @@ const Map<String, dynamic> seAddress = <String, dynamic>{
'siafi': '7107',
};
+const Map<String, dynamic> arealFaixa = <String, dynamic>{
+ 'localidade': 'Areal',
+ 'uf': 'RJ',
+ 'estado': 'Rio de Janeiro',
+ 'regiao': 'Sudeste',
+ 'ibge': '3300225',
+ 'cep_ini': '25845001',
+ 'cep_fim': '25849899',
+ 'ceps': 118,
+};
+
+String notFound({Map<String, dynamic>? faixa}) => jsonEncode(<String, dynamic>{
+ 'erro': 'cep_nao_encontrado',
+ 'mensagem': 'CEP não existe na base do DNE',
+ if (faixa != null) 'faixa': faixa,
+ });
+
const String csvHeader =
'cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,'
'ibge,gia,ddd,siafi';
@@ -184,16 +201,7 @@ void main() {
});
test('404 becomes CepNotFoundException and is not retried', () async {
- server.routes.respond(
- '/99999999',
- Reply(
- 404,
- jsonEncode(<String, String>{
- 'erro': 'cep_nao_encontrado',
- 'mensagem': 'CEP não existe na base do DNE',
- }),
- ),
- );
+ server.routes.respond('/99999999', Reply(404, notFound()));
await expectLater(
client.lookup('99999999'),
@@ -208,6 +216,127 @@ void main() {
expect(server.routes.count('/99999999'), 1);
});
+ test('a 404 with no faixa leaves it null', () async {
+ server.routes.respond('/99999999', Reply(404, notFound()));
+
+ await expectLater(
+ client.lookup('99999999'),
+ throwsA(
+ isA<CepNotFoundException>().having(
+ (CepNotFoundException e) => e.faixa,
+ 'faixa',
+ isNull,
+ ),
+ ),
+ );
+ });
+
+ test('a 404 carries the municipality the CEP would belong to', () async {
+ server.routes.respond(
+ '/25845000',
+ Reply(404, notFound(faixa: arealFaixa)),
+ );
+
+ await expectLater(
+ client.lookup('25845000'),
+ throwsA(
+ isA<CepNotFoundException>()
+ .having((CepNotFoundException e) => e.cep, 'cep', '25845000')
+ .having(
+ (CepNotFoundException e) => e.faixa,
+ 'faixa',
+ const Faixa(
+ localidade: 'Areal',
+ uf: 'RJ',
+ estado: 'Rio de Janeiro',
+ regiao: 'Sudeste',
+ ibge: '3300225',
+ cepIni: '25845001',
+ cepFim: '25849899',
+ ceps: 118,
+ ),
+ ),
+ ),
+ );
+ });
+
+ test('the faixa does not turn a 404 into an address', () async {
+ server.routes.respond(
+ '/25845000',
+ Reply(404, notFound(faixa: arealFaixa)),
+ );
+
+ await expectLater(
+ client.lookup('25845000'),
+ throwsA(isA<CepNotFoundException>()),
+ );
+ expect(server.routes.count('/25845000'), 1);
+ });
+
+ test('the message names the municipality when there is one', () async {
+ server.routes.respond(
+ '/25845000',
+ Reply(404, notFound(faixa: arealFaixa)),
+ );
+
+ try {
+ await client.lookup('25845000');
+ fail('expected CepNotFoundException');
+ } on CepNotFoundException catch (error) {
+ expect(error.toString(), contains('Areal/RJ'));
+ expect(error.toString(), contains('118 CEPs'));
+ }
+ });
+
+ test('an unreadable faixa is null, not a failure', () async {
+ final bodies = <String>[
+ notFound(faixa: <String, dynamic>{}),
+ notFound(faixa: <String, dynamic>{'uf': 'RJ'}),
+ jsonEncode(<String, dynamic>{
+ 'erro': 'cep_nao_encontrado',
+ 'faixa': 'Areal',
+ }),
+ jsonEncode(<String, dynamic>{
+ 'erro': 'cep_nao_encontrado',
+ 'faixa': <String>[],
+ }),
+ '<html>portal</html>',
+ '',
+ ];
+
+ for (final String body in bodies) {
+ server.routes.respond('/99999999', Reply(404, body));
+
+ await expectLater(
+ client.lookup('99999999'),
+ throwsA(
+ isA<CepNotFoundException>().having(
+ (CepNotFoundException e) => e.faixa,
+ 'faixa',
+ isNull,
+ ),
+ ),
+ reason: body,
+ );
+ }
+ });
+
+ test('a faixa missing the counts still names the municipality', () async {
+ server.routes.respond(
+ '/25845000',
+ Reply(404, notFound(faixa: <String, dynamic>{'localidade': 'Areal'})),
+ );
+
+ try {
+ await client.lookup('25845000');
+ fail('expected CepNotFoundException');
+ } on CepNotFoundException catch (error) {
+ expect(error.faixa!.localidade, 'Areal');
+ expect(error.faixa!.uf, '');
+ expect(error.faixa!.ceps, 0);
+ }
+ });
+
test('an invalid CEP never leaves the machine', () async {
await expectLater(
client.lookup('123'),
@@ -265,7 +394,7 @@ void main() {
server.routes.respond('/01001000', Reply(200, jsonEncode(seAddress)));
await client.lookup('01001000');
- expect(server.routes.userAgents.single, startsWith('cepis-dart/0.1.0'));
+ expect(server.routes.userAgents.single, startsWith('cepis-dart/0.2.0'));
});
});
@@ -628,5 +757,20 @@ void main() {
expect(one, two);
expect(one.hashCode, two.hashCode);
});
+
+ test('two identical faixas are equal', () {
+ final one = Faixa.fromJson(Map<String, dynamic>.of(arealFaixa));
+ final two = Faixa.fromJson(Map<String, dynamic>.of(arealFaixa));
+ expect(one, two);
+ expect(one.hashCode, two.hashCode);
+ });
+
+ test('a faixa without a municipality is nothing to report', () {
+ expect(Faixa.fromJson(<String, dynamic>{}), isNull);
+ expect(
+ Faixa.fromJson(<String, dynamic>{'localidade': '', 'uf': 'RJ'}),
+ isNull,
+ );
+ });
});
}