summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:38:22 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:38:22 -0300
commit8e719fc715eb7ae9aa1c842e633f15b5c6cd3688 (patch)
tree4afda3d13e1b772f2c9a5cf9ceea5e84c647ebdd
downloaddart-0.1.0.tar.gz
Initial importv0.1.0
-rw-r--r--.gitignore9
-rw-r--r--CHANGELOG.md6
-rw-r--r--LICENSE15
-rw-r--r--Makefile38
-rw-r--r--README.md233
-rw-r--r--analysis_options.yaml15
-rw-r--r--bin/cepis.dart93
-rw-r--r--lib/cepis.dart51
-rw-r--r--lib/src/client.dart496
-rw-r--r--lib/src/csv.dart86
-rw-r--r--lib/src/errors.dart112
-rw-r--r--lib/src/models.dart363
-rw-r--r--lib/src/version.dart5
-rw-r--r--pubspec.yaml17
-rw-r--r--test/cepis_test.dart632
-rw-r--r--test/server.dart126
16 files changed, 2297 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..17fd8d7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+.dart_tool/
+build/
+doc/api/
+*.exe
+
+# Written by `dart pub get`. Not committed: this is a library, so it must
+# resolve against whatever its dependents already have, and a lock file here
+# would pin only one development machine.
+pubspec.lock
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..5f9e0cb
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,6 @@
+# Changelog
+
+## 0.1.0
+
+- First release: `lookup`, `lookupMany`, `health` and `normalizeCep`, with
+ retries, full-jitter backoff and `Retry-After` handling.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..bf12d68
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,38 @@
+DART ?= dart
+
+.DEFAULT_GOAL := help
+.PHONY: help install test lint format build check clean
+
+help:
+ @echo 'Targets:'
+ @echo ' install resolve the dependencies'
+ @echo ' test run the test suite (dart test)'
+ @echo ' lint dart format --set-exit-if-changed + dart analyze (read-only)'
+ @echo ' format apply dart format (writes)'
+ @echo ' build compile the CLI to build/cepis'
+ @echo ' check lint + test + build, in that order'
+ @echo ' clean remove build artefacts'
+
+.dart_tool/package_config.json: pubspec.yaml
+ $(DART) pub get
+
+install: .dart_tool/package_config.json
+
+test: install
+ $(DART) test
+
+lint: install
+ $(DART) format --output=none --set-exit-if-changed .
+ $(DART) analyze --fatal-infos
+
+format: install
+ $(DART) format .
+
+build: install
+ @mkdir -p build
+ $(DART) compile exe bin/cepis.dart -o build/cepis
+
+check: lint test build
+
+clean:
+ rm -rf build .dart_tool
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ffb4cac
--- /dev/null
+++ b/README.md
@@ -0,0 +1,233 @@
+# cepis
+
+Dart client for the [public CEP API at cep.is](https://cep.is). Official
+Correios data (DNE), refreshed every two weeks. Pure Dart, so the same code runs
+on the VM, in AOT binaries and everywhere Flutter runs — Android, iOS, desktop
+and Web.
+
+```dart
+import 'package:cepis/cepis.dart' as cepis;
+
+final address = await cepis.lookup('01001-000');
+print('${address.logradouro} ${address.localidade} ${address.uf}');
+// Praça da Sé São Paulo SP
+```
+
+## Installation
+
+```sh
+dart pub add cepis
+# or, in a Flutter app: flutter pub add cepis
+```
+
+Requires Dart 3.6 or newer. One dependency, `package:http`: the SDK's own
+`HttpClient` does not exist on Flutter Web, and a client that breaks on one of
+Flutter's platforms is not a Flutter client.
+
+## Usage
+
+### A single lookup
+
+`lookup` accepts the CEP masked or unmasked and resolves to an `Address`:
+
+```dart
+final a = await cepis.lookup('01310-100');
+
+a.logradouro; // 'Avenida Paulista'
+a.localidade; // 'São Paulo'
+a.uf; // 'SP'
+a.ibge; // '3550308'
+a.cep; // '01310100' (always unmasked)
+a.formattedCep; // '01310-100'
+```
+
+Fields the database does not have for that CEP come back as empty strings, never
+`null`. `a.raw` reads the response as it arrived, including a field the API grew
+after this release.
+
+The data fields keep the API's own Portuguese names; everything else — methods,
+types, errors — is English. Only the casing changes, where Dart demands it: the
+API's `raio_m` is `geo.raioM` and its `base_gerada_em` is `health.baseGeradaEm`.
+
+### Coordinates
+
+About 57% of CEPs have a coordinate. When they do not, `geo` is `null`:
+
+```dart
+final a = await cepis.lookup('01310100');
+
+final geo = a.geo;
+if (geo != null) {
+ geo.lat; // -23.567202
+ geo.lon; // -46.649659
+ geo.raioM; // 1084 (the API's `raio_m`)
+ geo.precisao; // 'logradouro'
+}
+```
+
+**Check `precisao` before computing distance.** There are two kinds of point,
+and the difference between them is tens of kilometres:
+
+| `precisao` | typical radius | what the point is |
+| --- | --- | --- |
+| `logradouro` | 110–140 m | the stretch of street — good for maps and distance |
+| `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city |
+
+`geo.isPrecise` is the shorthand for `precisao == 'logradouro'`:
+
+```dart
+final geo = a.geo;
+final cost = geo != null && geo.isPrecise
+ ? shippingFrom(geo.lat, geo.lon)
+ : shippingFromMunicipality(a.ibge);
+```
+
+### Many CEPs at once
+
+Do not loop over `lookup`. Use `lookupMany`, which resolves up to 500 CEPs per
+request:
+
+```dart
+final ceps = <String>['01001000', '99999999', '20040020'];
+final result = await cepis.lookupMany(ceps);
+
+for (var i = 0; i < ceps.length; i++) {
+ final address = result[i];
+ print('${ceps[i]}: ${address == null ? 'not found' : address.localidade}');
+}
+```
+
+The returned list has **the same length and the same order** as the input. A CEP
+that does not exist, or is malformed, is `null` at its position — it does not
+vanish, and it does not throw.
+
+Lists longer than 500 are chunked automatically.
+
+> **Batches carry no coordinate.** The endpoint behind them is CSV, and the CSV
+> has no `geo` column. Every `Address` from `lookupMany` has a `null` `geo`,
+> including for CEPs that do have one. Need `geo`? Use `lookup`, one at a time.
+
+### API health
+
+Useful for two things: checking the API answers, and knowing **which version of
+the database** is live:
+
+```dart
+final h = await cepis.health();
+
+h.ok; // true
+h.dne; // '26071' identifier of the fortnightly database
+h.ceps; // 1596168
+h.baseGeradaEm; // '2026-07-24T03:48:28Z'
+```
+
+An API reporting itself degraded does not throw — that arrives in `h.status`.
+
+## Configuration
+
+For anything beyond the defaults, build a `Client` and reuse it. It keeps no
+state between calls:
+
+```dart
+final client = cepis.Client(
+ baseUrl: 'https://cep.is', // point at another instance
+ timeout: const Duration(seconds: 10), // per attempt, body read included
+ maxAttempts: 3, // counting the first; 1 disables retrying
+ backoffBase: const Duration(milliseconds: 500),
+ backoffMax: const Duration(seconds: 8), // cap on each wait
+ totalTimeout: const Duration(seconds: 15), // cap on the whole call
+);
+
+final address = await client.lookup('01001000');
+client.close();
+```
+
+`timeout` bounds a single attempt — request and body read together. The cap on
+all the attempts and the waits between them is `totalTimeout`.
+
+Retries cover 429, 5xx and network failures, with exponential backoff and full
+jitter; 400 and 404 are never retried.
+
+Every snippet here assumes the `import ... as cepis;` above; a plain
+`import 'package:cepis/cepis.dart';` puts `Client`, `Address` and the rest in
+scope unprefixed.
+
+`Client` also takes an `httpClient`, for sharing a connection pool or for a
+platform-native transport such as `cupertino_http` or `cronet_http`. A client
+passed that way is yours to close; `close()` leaves it alone.
+
+The top-level functions (`cepis.lookup`, `cepis.lookupMany`, `cepis.health`) use
+a shared default client, reachable as `cepis.defaultClient`. It applies
+process-wide, third-party packages included. Need a different configuration?
+Build your own.
+
+### Pointing at a local instance
+
+`baseUrl` is the only setting that usually changes between environments. The API
+repo brings a local instance up with `just up`:
+
+```dart
+final client = cepis.Client(baseUrl: 'http://localhost:8080');
+```
+
+From the command line, to confirm quickly that it answers:
+
+```sh
+dart run cepis --base-url http://localhost:8080 01001000
+```
+
+## Errors
+
+Everything derives from `CepisException`, which is `sealed` — a `switch` over it
+is checked for exhaustiveness. Below that, the split is by **the decision you
+have to make**, not by the HTTP status that caused it:
+
+| error | when | does retrying help? |
+| --- | --- | --- |
+| `InvalidCepException` | not 8 digits | no |
+| `CepNotFoundException` | well-formed, not in the database | no |
+| `RateLimitedException` | 429 | yes, later |
+| `ServerException` | 5xx | yes, later |
+| `NetworkException` | DNS, connection, timeout, TLS | yes, later |
+| `InvalidResponseException` | unintelligible response | probably not |
+
+```dart
+try {
+ final address = await cepis.lookup(cepFromForm);
+} on cepis.CepNotFoundException {
+ // ask the user for another CEP
+} on cepis.CepisException {
+ // transient failure: reschedule
+}
+```
+
+`RateLimitedException` exposes `retryAfter`, `ServerException` exposes `status`,
+and `NetworkException` exposes `cause`.
+
+`InvalidCepException` also implements `FormatException`, so code already
+handling malformed input during validation keeps working without knowing this
+library exists.
+
+`InvalidResponseException` almost always means something between you and the API
+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.
+
+## Development
+
+Everything runs through `make`, which shells out to `dart`:
+
+```sh
+make # list the targets
+make test # dart test
+make lint # dart format --set-exit-if-changed + dart analyze, touches nothing
+make format # apply dart format
+make build # compile the CLI to build/cepis
+make check # lint, then test, then build
+```
+
+The suite stands a real `HttpServer` up on a loopback port and points `baseUrl`
+at it: no network, and nothing about the HTTP layer is stubbed.
+
+## Licence
+
+ISC. See [LICENSE](LICENSE).
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..0f4efa5
--- /dev/null
+++ b/analysis_options.yaml
@@ -0,0 +1,15 @@
+include: package:lints/recommended.yaml
+
+analyzer:
+ language:
+ strict-casts: true
+ strict-inference: true
+ strict-raw-types: true
+
+linter:
+ rules:
+ - always_declare_return_types
+ - directives_ordering
+ - prefer_final_locals
+ - prefer_single_quotes
+ - unawaited_futures
diff --git a/bin/cepis.dart b/bin/cepis.dart
new file mode 100644
index 0000000..8eec681
--- /dev/null
+++ b/bin/cepis.dart
@@ -0,0 +1,93 @@
+/// Command-line entry point: resolve CEPs from a terminal.
+///
+/// ```sh
+/// dart run cepis 01001000
+/// dart run cepis --base-url http://localhost:8080 01001000 20040020
+/// ```
+library;
+
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:cepis/cepis.dart';
+
+const String _usage = '''
+usage: cepis [--base-url URL] [--health] [CEP...]
+
+ --base-url URL where the API lives (default $defaultBaseUrl)
+ --health report the API's health instead of looking anything up
+ -h, --help show this message
+
+One CEP prints the full record, coordinate included. Several go through the
+batch endpoint, which carries no coordinate.''';
+
+Future<int> run(List<String> arguments) async {
+ var baseUrl = defaultBaseUrl;
+ var wantHealth = false;
+ final ceps = <String>[];
+
+ for (var i = 0; i < arguments.length; i++) {
+ final argument = arguments[i];
+ if (argument == '-h' || argument == '--help') {
+ stdout.writeln(_usage);
+ return 0;
+ } else if (argument == '--health') {
+ wantHealth = true;
+ } else if (argument == '--base-url') {
+ if (i + 1 >= arguments.length) {
+ stderr.writeln('cepis: --base-url needs a value');
+ return 2;
+ }
+ baseUrl = arguments[++i];
+ } else if (argument.startsWith('-')) {
+ stderr.writeln('cepis: unknown option $argument');
+ return 2;
+ } else {
+ ceps.add(argument);
+ }
+ }
+
+ if (!wantHealth && ceps.isEmpty) {
+ stderr.writeln(_usage);
+ return 2;
+ }
+
+ final client = Client(baseUrl: baseUrl);
+ try {
+ if (wantHealth) {
+ final report = await client.health();
+ stdout.writeln(_encode(report.raw));
+ return report.ok ? 0 : 1;
+ }
+
+ if (ceps.length == 1) {
+ final address = await client.lookup(ceps.single);
+ stdout.writeln(_encode(address.raw));
+ return 0;
+ }
+
+ final result = await client.lookupMany(ceps);
+ for (var i = 0; i < ceps.length; i++) {
+ final address = result[i];
+ stdout.writeln(
+ address == null
+ ? '${ceps[i]}\tnot found'
+ : '${ceps[i]}\t${address.logradouro}\t${address.localidade}\t'
+ '${address.uf}',
+ );
+ }
+ return 0;
+ } on CepisException catch (error) {
+ stderr.writeln('cepis: ${error.message}');
+ return 1;
+ } finally {
+ client.close();
+ }
+}
+
+String _encode(Map<String, dynamic> data) =>
+ const JsonEncoder.withIndent(' ').convert(data);
+
+Future<void> main(List<String> arguments) async {
+ exitCode = await run(arguments);
+}
diff --git a/lib/cepis.dart b/lib/cepis.dart
new file mode 100644
index 0000000..2bc42b8
--- /dev/null
+++ b/lib/cepis.dart
@@ -0,0 +1,51 @@
+/// Dart client for the [public CEP API at cep.is](https://cep.is).
+///
+/// Official Correios data (DNE), refreshed every two weeks. Pure Dart, so the
+/// same code runs on the VM, in AOT binaries and everywhere Flutter runs,
+/// Web included.
+///
+/// ```dart
+/// import 'package:cepis/cepis.dart' as cepis;
+///
+/// final address = await cepis.lookup('01001-000');
+/// print('${address.logradouro} ${address.localidade} ${address.uf}');
+/// // Praça da Sé São Paulo SP
+/// ```
+///
+/// Data fields keep the API's own Portuguese names; everything else — methods,
+/// types, errors — is English.
+library;
+
+import 'src/client.dart';
+import 'src/models.dart';
+
+export 'src/client.dart'
+ show Client, defaultBaseUrl, maxBatch, normalizeCep, retriableStatus;
+export 'src/errors.dart'
+ show
+ CepNotFoundException,
+ CepisException,
+ InvalidCepException,
+ InvalidResponseException,
+ NetworkException,
+ RateLimitedException,
+ ServerException;
+export 'src/models.dart' show Address, Geo, Health;
+
+final Client _default = Client();
+
+/// The [Client] behind the top-level [lookup], [lookupMany] and [health].
+///
+/// It applies process-wide, third-party packages included. Need a different
+/// configuration? Build your own [Client] rather than reaching in here.
+Client get defaultClient => _default;
+
+/// Looks up a single CEP with [defaultClient]. See [Client.lookup].
+Future<Address> lookup(String cep) => _default.lookup(cep);
+
+/// Looks up many CEPs with [defaultClient]. See [Client.lookupMany].
+Future<List<Address?>> lookupMany(List<String> ceps) =>
+ _default.lookupMany(ceps);
+
+/// Queries the API's health with [defaultClient]. See [Client.health].
+Future<Health> health() => _default.health();
diff --git a/lib/src/client.dart b/lib/src/client.dart
new file mode 100644
index 0000000..9ddc165
--- /dev/null
+++ b/lib/src/client.dart
@@ -0,0 +1,496 @@
+/// The HTTP client, built on `package:http`.
+///
+/// Retry policy: 429 and the transient 5xx in [retriableStatus], plus network
+/// failures, are retried with exponentially backed-off, jittered waits. 400 and
+/// 404 are deliberately absent from that set — both are definitive answers
+/// about the request, so retrying returns the same thing more slowly while
+/// burning someone else's quota.
+library;
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:math';
+
+import 'package:http/http.dart' as http;
+
+import 'csv.dart';
+import 'errors.dart';
+import 'models.dart';
+import 'version.dart';
+
+/// Where the public API lives.
+const String defaultBaseUrl = 'https://cep.is';
+
+/// How many CEPs the `/csv` endpoint accepts in one request.
+const int maxBatch = 500;
+
+/// The statuses worth trying again. 400 and 404 are deliberately absent.
+const Set<int> retriableStatus = <int>{429, 500, 502, 503, 504};
+
+const String _accept = 'application/json, text/csv;q=0.9, */*;q=0.1';
+
+final RegExp _digitsOnly = RegExp(r'^\d{8}$');
+
+final RegExp _separators = RegExp(r'[-.\s]');
+
+final RegExp _imfFixdate = RegExp(
+ r'^[A-Za-z]{3},\s+(\d{2})\s+([A-Za-z]{3})\s+(\d{4})\s+'
+ r'(\d{2}):(\d{2}):(\d{2})\s+GMT$',
+);
+
+const Map<String, int> _months = <String, int>{
+ 'jan': 1,
+ 'feb': 2,
+ 'mar': 3,
+ 'apr': 4,
+ 'may': 5,
+ 'jun': 6,
+ 'jul': 7,
+ 'aug': 8,
+ 'sep': 9,
+ 'oct': 10,
+ 'nov': 11,
+ 'dec': 12,
+};
+
+/// Returns the CEP's 8 digits, accepting the mask.
+///
+/// `'01001-000'`, `'01001000'` and `' 01001-000 '` all become `'01001000'`.
+///
+/// Throws [InvalidCepException] unless exactly 8 digits remain. This runs
+/// **before** any request: a 7-digit CEP is the caller's bug, and spending a
+/// round trip for the server to say so wastes both sides' time.
+String normalizeCep(String value) {
+ final cleaned = value.trim().replaceAll(_separators, '');
+ if (!_digitsOnly.hasMatch(cleaned)) {
+ throw InvalidCepException(
+ "CEP must be 8 digits, got '$value'",
+ source: value,
+ );
+ }
+ return cleaned;
+}
+
+/// Client for the cep.is API.
+///
+/// Reuse one instance: the configuration is immutable and no method keeps state
+/// between calls, so it is safe to share anywhere in an isolate.
+///
+/// ```dart
+/// final client = Client(totalTimeout: Duration(seconds: 15));
+/// final address = await client.lookup('01001-000');
+/// ```
+///
+/// Call [close] when done, unless you passed your own `httpClient` — then it is
+/// yours to close.
+class Client {
+ /// Creates a client.
+ ///
+ /// [baseUrl] is where the API lives. Point it at a local instance (the API
+ /// repo brings one up on `http://localhost:8080` with `just up`) or at a test
+ /// server. It is the only parameter that usually changes between
+ /// environments. A trailing slash is stripped, so it cannot produce doubled
+ /// separators that some proxies normalise and others 404.
+ ///
+ /// [timeout] bounds a **single attempt** — request and body read together.
+ /// `package:http` reads the whole response before returning, so this is one
+ /// deadline over the exchange rather than one per socket operation. The cap
+ /// on the whole call, retries and waits included, is [totalTimeout].
+ ///
+ /// [maxAttempts] counts the first attempt; `1` disables retrying. The default
+ /// of `3` covers the common case (one transient failure) without turning a
+ /// real outage into a long wait.
+ ///
+ /// [backoffBase] is the base of the exponential wait. The actual wait is
+ /// drawn from `[0, base * 2^(n-1)]` — *full jitter*. The randomness is not
+ /// decoration: without it, N clients that fail together come back together
+ /// and reproduce the spike that took the service down.
+ ///
+ /// [backoffMax] caps each individual wait. It applies to a server-sent
+ /// `Retry-After` too, or a `Retry-After: 3600` from a misconfigured proxy
+ /// would park the caller for an hour.
+ ///
+ /// [totalTimeout] caps the whole call, attempts and waits included. `null`
+ /// leaves [maxAttempts] as the only bound. Set it in code serving a web
+ /// request: without it the worst case is `maxAttempts * timeout` plus the sum
+ /// of the backoffs. When this cap is what cuts an attempt short, the error
+ /// reported is still the API's last real answer rather than the truncation —
+ /// a 503 you already know about is a better diagnosis than a timeout this
+ /// client caused itself.
+ ///
+ /// [userAgent] overrides the `User-Agent`. The default identifies the library
+ /// and its version, which is what lets the server diagnose an old client
+ /// misbehaving.
+ ///
+ /// [httpClient] replaces the transport — pass one to share a connection pool,
+ /// or to use a platform-native implementation such as `cupertino_http` or
+ /// `cronet_http` under Flutter. A client passed here is never closed by
+ /// [close].
+ ///
+ /// Throws [ArgumentError] for a non-positive duration or fewer than one
+ /// attempt.
+ Client({
+ String baseUrl = defaultBaseUrl,
+ Duration timeout = const Duration(seconds: 10),
+ int maxAttempts = 3,
+ Duration backoffBase = const Duration(milliseconds: 500),
+ Duration backoffMax = const Duration(seconds: 8),
+ Duration? totalTimeout,
+ String? userAgent,
+ http.Client? httpClient,
+ }) : baseUrl = _stripTrailingSlashes(baseUrl),
+ timeout = _positive(timeout, 'timeout'),
+ maxAttempts = _atLeastOne(maxAttempts),
+ backoffBase = _positive(backoffBase, 'backoffBase'),
+ backoffMax = _positive(backoffMax, 'backoffMax'),
+ totalTimeout = totalTimeout == null
+ ? null
+ : _positive(totalTimeout, 'totalTimeout'),
+ userAgent = userAgent ?? 'cepis-dart/$packageVersion (+https://cep.is)',
+ _http = httpClient ?? http.Client(),
+ _ownsHttp = httpClient == null;
+
+ /// The API root, without a trailing slash.
+ final String baseUrl;
+
+ /// How long a single attempt may take, request and body read together.
+ final Duration timeout;
+
+ /// How many attempts in total, counting the first.
+ final int maxAttempts;
+
+ /// Base of the exponential wait.
+ final Duration backoffBase;
+
+ /// Cap on each individual wait, a server-sent `Retry-After` included.
+ final Duration backoffMax;
+
+ /// Cap on the whole call, or `null` for none.
+ final Duration? totalTimeout;
+
+ /// The `User-Agent` sent with every request.
+ final String userAgent;
+
+ final http.Client _http;
+ final bool _ownsHttp;
+ final Random _random = Random();
+
+ /// Looks up a single CEP.
+ ///
+ /// ```dart
+ /// final address = await client.lookup('01001-000');
+ /// address.localidade; // 'São Paulo'
+ /// ```
+ ///
+ /// A 400 from the API surfaces as [InvalidCepException] carrying the API's
+ /// own message: the format already passed local validation, so the server
+ /// knows something this client does not.
+ ///
+ /// 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;
+ /// [ServerException] on 5xx after every attempt; [NetworkException] when the
+ /// request never completed; [InvalidResponseException] on a success status
+ /// with an unreadable body.
+ Future<Address> lookup(String cep) async {
+ final number = normalizeCep(cep);
+ final reply = await _request(_uri('/$number'));
+
+ if (reply.status == 404) {
+ throw CepNotFoundException(
+ 'CEP $number does not exist in the DNE database',
+ cep: number,
+ );
+ }
+ if (reply.status == 400) {
+ throw InvalidCepException(
+ _errorMessage(reply.body, 'CEP $number rejected'),
+ source: cep,
+ );
+ }
+
+ return Address.fromJson(_json(reply.body));
+ }
+
+ /// Looks up many CEPs in one batch.
+ ///
+ /// Returns a list of **the same length and in the same order** as the input.
+ /// A CEP that does not exist becomes `null` at its position — it does not
+ /// vanish from the list, and it does not throw. That is the right difference
+ /// from [lookup]: in a batch of a thousand, one bad CEP must not cost the
+ /// other nine hundred and ninety-nine.
+ ///
+ /// A malformed CEP also becomes `null`, for the same reason, rather than
+ /// throwing [InvalidCepException]. To know *which* one was malformed,
+ /// validate first with [normalizeCep].
+ ///
+ /// Input is chunked into blocks of [maxBatch] automatically.
+ ///
+ /// **Batches carry no coordinate.** The endpoint behind this is CSV, and the
+ /// CSV has no `geo` column. Every [Address] returned here has a `null`
+ /// [Address.geo], including for CEPs that do have one. Need the coordinate?
+ /// Use [lookup], one at a time.
+ Future<List<Address?>> lookupMany(List<String> ceps) async {
+ if (ceps.isEmpty) {
+ return <Address?>[];
+ }
+
+ final result = <Address?>[];
+ for (var start = 0; start < ceps.length; start += maxBatch) {
+ final end = min(start + maxBatch, ceps.length);
+ result.addAll(await _lookupChunk(ceps.sublist(start, end)));
+ }
+ return result;
+ }
+
+ /// Queries `GET /health`: does the API answer, and with which database.
+ ///
+ /// Does not throw for an API reporting itself degraded — that arrives in
+ /// [Health.status], and checking health is exactly when you want the
+ /// diagnosis rather than an exception. Network failures still throw.
+ Future<Health> health() async {
+ final reply = await _request(
+ _uri('/health'),
+ acceptedStatus: const <int>[200, 503],
+ );
+ return Health.fromJson(_json(reply.body));
+ }
+
+ /// Releases the underlying transport.
+ ///
+ /// Does nothing when the transport was supplied through the `httpClient`
+ /// parameter: that one belongs to the caller.
+ void close() {
+ if (_ownsHttp) {
+ _http.close();
+ }
+ }
+
+ Future<List<Address?>> _lookupChunk(List<String> ceps) async {
+ final reply = await _request(
+ _uri('/csv', <String, String>{'ceps': ceps.join(',')}),
+ );
+
+ final rows = <Address?>[];
+ for (final record in parseCsv(reply.body)) {
+ if ((record['localidade'] ?? '').trim().isEmpty) {
+ rows.add(null);
+ continue;
+ }
+ rows.add(Address.fromJson(record));
+ }
+
+ while (rows.length < ceps.length) {
+ rows.add(null);
+ }
+ return rows.sublist(0, ceps.length);
+ }
+
+ Uri _uri(String path, [Map<String, String>? query]) {
+ final uri = Uri.parse('$baseUrl$path');
+ return query == null ? uri : uri.replace(queryParameters: query);
+ }
+
+ Map<String, dynamic> _json(String body) {
+ final Object? data;
+ try {
+ data = jsonDecode(body);
+ } on FormatException {
+ throw InvalidResponseException(
+ 'response is not valid JSON — something between you and the API is '
+ 'answering instead of it: ${_snippet(body)}',
+ );
+ }
+ if (data is! Map<String, dynamic>) {
+ throw InvalidResponseException(
+ 'expected a JSON object, got ${data.runtimeType}',
+ );
+ }
+ return data;
+ }
+
+ Duration _backoff(int attempt, Duration? retryAfter) {
+ if (retryAfter != null) {
+ return retryAfter < backoffMax ? retryAfter : backoffMax;
+ }
+ final exponent = attempt - 1 > 30 ? 30 : attempt - 1;
+ final ceiling = min(
+ backoffBase.inMicroseconds * (1 << exponent),
+ backoffMax.inMicroseconds,
+ );
+ return Duration(microseconds: _random.nextInt(max(ceiling, 1)));
+ }
+
+ Future<_Reply> _request(
+ Uri url, {
+ List<int> acceptedStatus = const <int>[200],
+ }) async {
+ final elapsed = Stopwatch()..start();
+ final budget = totalTimeout;
+
+ int? lastStatus;
+ Duration? lastRetryAfter;
+ Object? lastFailure;
+
+ for (var attempt = 1; attempt <= maxAttempts; attempt++) {
+ var deadline = timeout;
+ var truncated = false;
+ if (budget != null) {
+ final remaining = budget - elapsed.elapsed;
+ if (remaining <= Duration.zero) {
+ break;
+ }
+ if (remaining < deadline) {
+ deadline = remaining;
+ truncated = true;
+ }
+ }
+
+ try {
+ final response = await _http.get(
+ url,
+ headers: <String, String>{
+ 'User-Agent': userAgent,
+ 'Accept': _accept,
+ },
+ ).timeout(deadline);
+
+ if (acceptedStatus.contains(response.statusCode) ||
+ !retriableStatus.contains(response.statusCode)) {
+ return _Reply(response.statusCode, _decode(response.bodyBytes));
+ }
+
+ lastStatus = response.statusCode;
+ lastRetryAfter = _retryAfter(response.headers['retry-after']);
+ lastFailure = null;
+ } on Exception catch (error) {
+ if (truncated && error is TimeoutException && lastStatus != null) {
+ break;
+ }
+ lastFailure = error;
+ lastStatus = null;
+ lastRetryAfter = null;
+ }
+
+ if (attempt == maxAttempts) {
+ break;
+ }
+
+ var wait = _backoff(attempt, lastRetryAfter);
+ if (budget != null) {
+ final remaining = budget - elapsed.elapsed;
+ if (remaining <= Duration.zero) {
+ break;
+ }
+ if (remaining < wait) {
+ wait = remaining;
+ }
+ }
+ await Future<void>.delayed(wait);
+ }
+
+ final suffix = '(after $maxAttempts attempt(s))';
+ if (lastStatus == 429) {
+ throw RateLimitedException(
+ 'rate limit reached at $url $suffix',
+ retryAfter: lastRetryAfter,
+ );
+ }
+ if (lastStatus != null) {
+ throw ServerException(
+ 'the API answered $lastStatus at $url $suffix',
+ status: lastStatus,
+ );
+ }
+ throw NetworkException(
+ 'could not reach $url $suffix: $lastFailure',
+ cause: lastFailure,
+ );
+ }
+
+ static String _stripTrailingSlashes(String value) {
+ var end = value.length;
+ while (end > 0 && value[end - 1] == '/') {
+ end--;
+ }
+ return value.substring(0, end);
+ }
+
+ static Duration _positive(Duration value, String name) {
+ if (value <= Duration.zero) {
+ throw ArgumentError.value(value, name, 'must be > 0');
+ }
+ return value;
+ }
+
+ static int _atLeastOne(int value) {
+ if (value < 1) {
+ throw ArgumentError.value(value, 'maxAttempts', 'must be >= 1');
+ }
+ return value;
+ }
+}
+
+class _Reply {
+ const _Reply(this.status, this.body);
+
+ final int status;
+ final String body;
+}
+
+String _decode(List<int> bytes) => utf8.decode(bytes, allowMalformed: true);
+
+String _snippet(String body) =>
+ body.length <= 120 ? body : '${body.substring(0, 120)}…';
+
+String _errorMessage(String body, String fallback) {
+ try {
+ final Object? data = jsonDecode(body);
+ if (data is Map<String, dynamic>) {
+ final message = data['mensagem'];
+ if (message is String && message.isNotEmpty) {
+ return message;
+ }
+ }
+ } on FormatException {
+ return fallback;
+ }
+ return fallback;
+}
+
+Duration? _retryAfter(String? value) {
+ if (value == null) {
+ return null;
+ }
+ final header = value.trim();
+ if (header.isEmpty) {
+ return null;
+ }
+
+ final seconds = double.tryParse(header);
+ if (seconds != null) {
+ if (seconds <= 0) {
+ return Duration.zero;
+ }
+ return Duration(microseconds: (seconds * 1000000).round());
+ }
+
+ final match = _imfFixdate.firstMatch(header);
+ if (match == null) {
+ return null;
+ }
+ final month = _months[match.group(2)!.toLowerCase()];
+ if (month == null) {
+ return null;
+ }
+ final target = DateTime.utc(
+ int.parse(match.group(3)!),
+ month,
+ int.parse(match.group(1)!),
+ int.parse(match.group(4)!),
+ int.parse(match.group(5)!),
+ int.parse(match.group(6)!),
+ );
+ final delta = target.difference(DateTime.now().toUtc());
+ return delta.isNegative ? Duration.zero : delta;
+}
diff --git a/lib/src/csv.dart b/lib/src/csv.dart
new file mode 100644
index 0000000..ab2f0be
--- /dev/null
+++ b/lib/src/csv.dart
@@ -0,0 +1,86 @@
+/// A minimal RFC 4180 reader, for the `/csv` batch endpoint.
+///
+/// Values may contain commas and are then double-quoted with `""` escaping, so
+/// splitting on `,` corrupts every row that carries a street name like
+/// `Rua Um, Dois`. This is here because the Dart SDK ships no CSV parser and
+/// this library keeps its dependency list to one entry.
+library;
+
+const String _bom = '\u{FEFF}';
+
+/// Parses CSV text into records keyed by its header row.
+///
+/// A leading byte-order mark is dropped, `CRLF` and `LF` are both accepted, and
+/// a row shorter than the header has its remaining columns filled with the
+/// empty string. An empty input yields no records.
+List<Map<String, String>> parseCsv(String text) {
+ final rows = _parseRows(text.startsWith(_bom) ? text.substring(1) : text);
+ if (rows.isEmpty) {
+ return <Map<String, String>>[];
+ }
+
+ final header = rows.removeAt(0);
+ final records = <Map<String, String>>[];
+ for (final row in rows) {
+ if (row.length == 1 && row.first.isEmpty) {
+ continue;
+ }
+ final record = <String, String>{};
+ for (var i = 0; i < header.length; i++) {
+ record[header[i]] = i < row.length ? row[i] : '';
+ }
+ records.add(record);
+ }
+ return records;
+}
+
+List<List<String>> _parseRows(String text) {
+ final rows = <List<String>>[];
+ final field = StringBuffer();
+ var row = <String>[];
+ var quoted = false;
+ var started = false;
+
+ for (var i = 0; i < text.length; i++) {
+ final char = text[i];
+
+ if (quoted) {
+ if (char == '"') {
+ if (i + 1 < text.length && text[i + 1] == '"') {
+ field.write('"');
+ i++;
+ } else {
+ quoted = false;
+ }
+ } else {
+ field.write(char);
+ }
+ continue;
+ }
+
+ if (char == '"') {
+ quoted = true;
+ started = true;
+ } else if (char == ',') {
+ row.add(field.toString());
+ field.clear();
+ started = true;
+ } else if (char == '\n') {
+ row.add(field.toString());
+ field.clear();
+ rows.add(row);
+ row = <String>[];
+ started = false;
+ } else if (char != '\r') {
+ field.write(char);
+ started = true;
+ }
+ }
+
+ if (started || field.isNotEmpty || row.isNotEmpty) {
+ row.add(field.toString());
+ rows.add(row);
+ }
+
+ return rows;
+}
diff --git a/lib/src/errors.dart b/lib/src/errors.dart
new file mode 100644
index 0000000..24b35c4
--- /dev/null
+++ b/lib/src/errors.dart
@@ -0,0 +1,112 @@
+/// Exception hierarchy.
+///
+/// Everything derives from [CepisException], so callers who only care that
+/// *something* went wrong can catch one type. It is `sealed`, so a `switch`
+/// over it is checked for exhaustiveness by the compiler.
+///
+/// Below that, the split is by **the decision the caller has to make**, not by
+/// the HTTP status that caused it:
+///
+/// - [InvalidCepException] and [CepNotFoundException] are problems with the
+/// *request*. Retrying is pointless — the same CEP will fail again.
+/// - [NetworkException], [ServerException] and [RateLimitedException] are
+/// problems with *transport*. Retrying may help, and the client already
+/// retried on its own before throwing any of them.
+///
+/// That split is why [CepNotFoundException] is not "the HTTP 404 exception":
+/// the client also throws [InvalidCepException] without making any request at
+/// all, when the CEP is malformed before it ever leaves the machine.
+library;
+
+/// 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
+/// exhaustive without a default branch.
+sealed class CepisException implements Exception {
+ /// Creates an exception carrying [message].
+ const CepisException(this.message);
+
+ /// Human-readable description of what went wrong.
+ final String message;
+
+ @override
+ String toString() => '$runtimeType: $message';
+}
+
+/// The CEP is not 8 digits.
+///
+/// Deliberately also a [FormatException]: this is a malformed-input error, and
+/// code that already handles `FormatException` while parsing user input keeps
+/// working without knowing this library exists.
+///
+/// Usually thrown **without** a request ever being made — the client checks the
+/// format before going to the network. The one exception is a `400` from the
+/// API, which carries the API's own message: the value already passed local
+/// validation, so the server knows something this client does not.
+final class InvalidCepException extends CepisException
+ implements FormatException {
+ /// Creates the error for [message], optionally recording the [source] value
+ /// that was rejected.
+ const InvalidCepException(super.message, {this.source, this.offset});
+
+ /// The value that was rejected, as the caller supplied it.
+ @override
+ final Object? source;
+
+ /// Always `null`; present only to satisfy [FormatException].
+ @override
+ final int? offset;
+}
+
+/// The CEP is well-formed but does not exist in the DNE database.
+final class CepNotFoundException extends CepisException {
+ /// Creates the error for [message], recording the normalised [cep].
+ const CepNotFoundException(super.message, {this.cep});
+
+ /// The CEP that was looked up, normalised to 8 digits.
+ final String? cep;
+}
+
+/// The request never completed: DNS, refused connection, timeout, TLS.
+///
+/// Thrown only after every configured attempt has failed.
+final class NetworkException extends CepisException {
+ /// Creates the error for [message], recording the underlying [cause].
+ const NetworkException(super.message, {this.cause});
+
+ /// The transport error that ended the last attempt, when there was one.
+ final Object? cause;
+}
+
+/// The API answered 5xx on every attempt.
+final class ServerException extends CepisException {
+ /// Creates the error for [message] and the [status] seen last.
+ const ServerException(super.message, {required this.status});
+
+ /// The HTTP status seen on the last attempt.
+ final int status;
+}
+
+/// The API (or the edge in front of it) answered 429.
+///
+/// [retryAfter] is what the server asked for, or `null` if it did not say. The
+/// client already honoured that value across the attempts it made — capped at
+/// the client's `backoffMax` — and it is exposed here for callers who want to
+/// reschedule the work.
+final class RateLimitedException extends CepisException {
+ /// Creates the error for [message] and the server's [retryAfter], if any.
+ const RateLimitedException(super.message, {this.retryAfter});
+
+ /// How long the server asked the caller to wait, or `null` if it did not say.
+ final Duration? retryAfter;
+}
+
+/// A success status came back with a body that cannot be parsed.
+///
+/// In practice this means something between you and the API 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.
+final class InvalidResponseException extends CepisException {
+ /// Creates the error for [message].
+ const InvalidResponseException(super.message);
+}
diff --git a/lib/src/models.dart b/lib/src/models.dart
new file mode 100644
index 0000000..57a008f
--- /dev/null
+++ b/lib/src/models.dart
@@ -0,0 +1,363 @@
+/// The data the API returns, as objects.
+///
+/// **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`,
+/// `localidade`. Everything else in this library is English; the fields are the
+/// one deliberate exception.
+///
+/// The reason is fidelity: what you read in the JSON at
+/// <https://cep.is/01001000> is exactly what you access on the object.
+/// Translating to `street`/`city` would mean maintaining a field-by-field
+/// mapping and forcing anyone reading the API docs alongside this library to
+/// translate back in their head. The values are Portuguese either way — they
+/// 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.
+library;
+
+/// The CEP's coordinate, when the database has one.
+///
+/// The point is the **median** of the coordinates of that CEP's addresses, not
+/// the mean: one badly geocoded address 40 km away shifts a mean and does not
+/// move a median.
+///
+/// Always check [precisao] — or the [isPrecise] shorthand — before using
+/// [lat]/[lon] for distance:
+///
+/// - `'logradouro'`: the point is a stretch of street. [raioM] lands around
+/// 100–150 m. Good for maps and for distance.
+/// - `'localidade'`: the CEP *is* the whole municipality (a city-wide general
+/// CEP), and the point is its centroid. [raioM] runs from 7 km to 40 km.
+/// Computing shipping from this is wrong by tens of kilometres.
+class Geo {
+ /// Creates a coordinate from its parts.
+ const Geo({
+ required this.lat,
+ required this.lon,
+ required this.raioM,
+ required this.precisao,
+ });
+
+ /// Latitude in decimal degrees.
+ final double lat;
+
+ /// Longitude in decimal degrees.
+ final double lon;
+
+ /// Radius in metres containing 90% of that CEP's addresses.
+ ///
+ /// The API spells this field `raio_m`; Dart's naming rules make it `raioM`
+ /// here. It exists precisely so the difference between a street-level point
+ /// and a municipality centroid cannot pass unnoticed.
+ final int raioM;
+
+ /// Either `'logradouro'` (a stretch of street) or `'localidade'` (the
+ /// municipality centroid). Treat any other value as imprecise.
+ final String precisao;
+
+ /// Builds a coordinate from the API's `geo` block, or returns `null`.
+ ///
+ /// A malformed block yields `null` rather than throwing: it is not worth
+ /// losing the whole address over, since the rest of the response is still
+ /// useful.
+ static Geo? fromJson(Map<String, dynamic> data) {
+ final lat = _toDouble(data['lat']);
+ final lon = _toDouble(data['lon']);
+ final raioM = _toInt(data['raio_m']);
+ final precisao = data['precisao'];
+ if (lat == null || lon == null || raioM == null || precisao == null) {
+ return null;
+ }
+ return Geo(
+ lat: lat,
+ lon: lon,
+ raioM: raioM,
+ precisao: precisao is String ? precisao : precisao.toString(),
+ );
+ }
+
+ /// `true` when the coordinate points at a street, not a municipality.
+ ///
+ /// Shorthand for `precisao == 'logradouro'`, which is the check to make
+ /// before using the point to measure distance.
+ bool get isPrecise => precisao == 'logradouro';
+
+ @override
+ bool operator ==(Object other) =>
+ other is Geo &&
+ other.lat == lat &&
+ other.lon == lon &&
+ other.raioM == raioM &&
+ other.precisao == precisao;
+
+ @override
+ int get hashCode => Object.hash(lat, lon, raioM, precisao);
+
+ @override
+ String toString() =>
+ 'Geo(lat: $lat, lon: $lon, raioM: $raioM, precisao: $precisao)';
+}
+
+/// A CEP and everything the database knows about it.
+///
+/// Fields the database does not have for that CEP come back as empty strings,
+/// never `null` — that is what the API returns, and changing it here would
+/// create a difference between reading the JSON and reading this library.
+///
+/// [geo] is the exception: it is `null` when there is no coordinate. There the
+/// difference is real, because the API **omits** the whole field in that case
+/// (about 43% of CEPs) rather than sending null.
+///
+/// [raw] holds the response exactly as it arrived, uninterpreted, so a field
+/// the API grows before this library is updated is not lost.
+class Address {
+ /// Creates an address from its parts. Every string field is required, so a
+ /// missing value is an explicit `''` rather than an accident.
+ const Address({
+ required this.cep,
+ required this.logradouro,
+ required this.complemento,
+ required this.unidade,
+ required this.bairro,
+ required this.localidade,
+ required this.uf,
+ required this.estado,
+ required this.regiao,
+ required this.ibge,
+ required this.gia,
+ required this.ddd,
+ required this.siafi,
+ this.geo,
+ this.raw = const <String, dynamic>{},
+ });
+
+ /// The 8 digits, always unmasked. See [formattedCep] for `01001-000`.
+ final String cep;
+
+ /// Street, avenue or square.
+ final String logradouro;
+
+ /// Extra qualifier on the street, such as `'lado ímpar'`.
+ final String complemento;
+
+ /// Name of the large consumer or public body owning an exclusive CEP.
+ final String unidade;
+
+ /// Neighbourhood.
+ final String bairro;
+
+ /// Municipality.
+ final String localidade;
+
+ /// Two-letter state code, such as `'SP'`.
+ 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;
+
+ /// GIA code, São Paulo state tax administration only.
+ final String gia;
+
+ /// Area code for telephone numbers.
+ final String ddd;
+
+ /// SIAFI municipality code, federal government accounting.
+ final String siafi;
+
+ /// The coordinate, or `null` when the database has none for this CEP.
+ ///
+ /// Always `null` for an address that came out of a batch — see
+ /// `Client.lookupMany`.
+ final Geo? geo;
+
+ /// The response exactly as it arrived, including fields this release does not
+ /// model. Unmodifiable.
+ final Map<String, dynamic> raw;
+
+ /// Builds an address from the map the API returned.
+ ///
+ /// Unknown fields are ignored during construction but preserved in [raw]. A
+ /// new field in the API therefore cannot break this library.
+ ///
+ /// A malformed `geo` block yields a `null` [geo] rather than propagating the
+ /// error.
+ factory Address.fromJson(Map<String, dynamic> data) {
+ final block = data['geo'];
+ return Address(
+ cep: _text(data['cep']),
+ logradouro: _text(data['logradouro']),
+ complemento: _text(data['complemento']),
+ unidade: _text(data['unidade']),
+ bairro: _text(data['bairro']),
+ localidade: _text(data['localidade']),
+ uf: _text(data['uf']),
+ estado: _text(data['estado']),
+ regiao: _text(data['regiao']),
+ ibge: _text(data['ibge']),
+ gia: _text(data['gia']),
+ ddd: _text(data['ddd']),
+ siafi: _text(data['siafi']),
+ geo: block is Map<String, dynamic> ? Geo.fromJson(block) : null,
+ raw: Map<String, dynamic>.unmodifiable(data),
+ );
+ }
+
+ /// The CEP with its mask: `01001-000`.
+ ///
+ /// The API always returns it unmasked; formatting is presentation, and it
+ /// lives here so it does not become a repeated interpolation in every project
+ /// that uses this.
+ String get formattedCep =>
+ cep.length == 8 ? '${cep.substring(0, 5)}-${cep.substring(5)}' : cep;
+
+ /// Equality covers every modelled field, [geo] included. [raw] is excluded:
+ /// two responses that differ only in a field this release does not model are
+ /// the same address as far as this library is concerned.
+ @override
+ bool operator ==(Object other) =>
+ other is Address &&
+ other.cep == cep &&
+ other.logradouro == logradouro &&
+ other.complemento == complemento &&
+ other.unidade == unidade &&
+ other.bairro == bairro &&
+ other.localidade == localidade &&
+ other.uf == uf &&
+ other.estado == estado &&
+ other.regiao == regiao &&
+ other.ibge == ibge &&
+ other.gia == gia &&
+ other.ddd == ddd &&
+ other.siafi == siafi &&
+ other.geo == geo;
+
+ @override
+ int get hashCode => Object.hash(
+ cep,
+ logradouro,
+ complemento,
+ unidade,
+ bairro,
+ localidade,
+ uf,
+ estado,
+ regiao,
+ ibge,
+ gia,
+ ddd,
+ siafi,
+ geo,
+ );
+
+ @override
+ String toString() =>
+ 'Address(cep: $cep, logradouro: $logradouro, bairro: $bairro, '
+ 'localidade: $localidade, uf: $uf, geo: $geo)';
+}
+
+/// The response of `GET /health`.
+///
+/// Good for two things: checking that the API answers, and knowing **which
+/// version of the database** is live. The DNE database changes every two weeks;
+/// [dne] identifies it.
+class Health {
+ /// Creates a health report from its parts.
+ const Health({
+ required this.status,
+ required this.dne,
+ required this.ceps,
+ required this.geo,
+ required this.baseGeradaEm,
+ this.raw = const <String, dynamic>{},
+ });
+
+ /// `'ok'` when the API considers itself healthy. See [ok].
+ final String status;
+
+ /// Identifier of the fortnightly DNE database currently loaded.
+ final String dne;
+
+ /// How many CEPs that database holds.
+ final int ceps;
+
+ /// How many of those CEPs carry a coordinate.
+ final int geo;
+
+ /// When the database was generated, as an ISO 8601 instant in UTC.
+ ///
+ /// The API spells this field `base_gerada_em`; Dart's naming rules make it
+ /// `baseGeradaEm` here.
+ final String baseGeradaEm;
+
+ /// The response exactly as it arrived, including fields this release does not
+ /// model. Unmodifiable.
+ final Map<String, dynamic> raw;
+
+ /// Builds a health report from the map the API returned.
+ factory Health.fromJson(Map<String, dynamic> data) => Health(
+ status: _text(data['status']),
+ dne: _text(data['dne']),
+ ceps: _toInt(data['ceps']) ?? 0,
+ geo: _toInt(data['geo']) ?? 0,
+ baseGeradaEm: _text(data['base_gerada_em']),
+ raw: Map<String, dynamic>.unmodifiable(data),
+ );
+
+ /// `true` when the API reports itself healthy.
+ bool get ok => status == 'ok';
+
+ @override
+ bool operator ==(Object other) =>
+ other is Health &&
+ other.status == status &&
+ other.dne == dne &&
+ other.ceps == ceps &&
+ other.geo == geo &&
+ other.baseGeradaEm == baseGeradaEm;
+
+ @override
+ int get hashCode => Object.hash(status, dne, ceps, geo, baseGeradaEm);
+
+ @override
+ String toString() =>
+ 'Health(status: $status, dne: $dne, ceps: $ceps, geo: $geo, '
+ 'baseGeradaEm: $baseGeradaEm)';
+}
+
+String _text(Object? value) {
+ if (value == null) {
+ return '';
+ }
+ return value is String ? value : value.toString();
+}
+
+double? _toDouble(Object? value) {
+ if (value is num) {
+ return value.toDouble();
+ }
+ if (value is String) {
+ return double.tryParse(value);
+ }
+ return null;
+}
+
+int? _toInt(Object? value) {
+ if (value is int) {
+ return value;
+ }
+ if (value is num) {
+ return value.toInt();
+ }
+ if (value is String) {
+ return int.tryParse(value);
+ }
+ return null;
+}
diff --git a/lib/src/version.dart b/lib/src/version.dart
new file mode 100644
index 0000000..a77195d
--- /dev/null
+++ b/lib/src/version.dart
@@ -0,0 +1,5 @@
+/// The library's own version, kept in step with `pubspec.yaml`.
+library;
+
+/// Version of this package, as it appears in the default `User-Agent`.
+const String packageVersion = '0.1.0';
diff --git a/pubspec.yaml b/pubspec.yaml
new file mode 100644
index 0000000..68d10c7
--- /dev/null
+++ b/pubspec.yaml
@@ -0,0 +1,17 @@
+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
+repository: https://git.cep.is/dart
+issue_tracker: https://git.cep.is/dart/issues
+
+environment:
+ sdk: ^3.6.0
+
+dependencies:
+ http: ^1.2.0
+
+dev_dependencies:
+ lints: ^5.0.0
+ test: ^1.25.0
diff --git a/test/cepis_test.dart b/test/cepis_test.dart
new file mode 100644
index 0000000..54df88f
--- /dev/null
+++ b/test/cepis_test.dart
@@ -0,0 +1,632 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:cepis/cepis.dart';
+import 'package:cepis/src/csv.dart';
+import 'package:test/test.dart';
+
+import 'server.dart';
+
+const Map<String, dynamic> seAddress = <String, dynamic>{
+ 'cep': '01001000',
+ 'logradouro': 'Praça da Sé',
+ 'complemento': 'lado ímpar',
+ 'unidade': '',
+ 'bairro': 'Sé',
+ 'localidade': 'São Paulo',
+ 'uf': 'SP',
+ 'estado': 'São Paulo',
+ 'regiao': 'Sudeste',
+ 'ibge': '3550308',
+ 'gia': '1004',
+ 'ddd': '11',
+ 'siafi': '7107',
+};
+
+const String csvHeader =
+ 'cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,'
+ 'ibge,gia,ddd,siafi';
+
+String csv(List<String> rows) => '$csvHeader\n${rows.join('\n')}\n';
+
+String batchPath(List<String> ceps) {
+ final query = Uri(
+ queryParameters: <String, String>{'ceps': ceps.join(',')},
+ ).query;
+ return '/csv?$query';
+}
+
+Map<String, dynamic> withGeo(Map<String, dynamic> geo) =>
+ <String, dynamic>{...seAddress, 'geo': geo};
+
+void main() {
+ late FakeServer server;
+ late Client client;
+
+ setUp(() async {
+ server = await FakeServer.start();
+ client = Client(
+ baseUrl: server.url,
+ timeout: const Duration(seconds: 5),
+ backoffBase: const Duration(milliseconds: 1),
+ backoffMax: const Duration(milliseconds: 10),
+ );
+ });
+
+ tearDown(() async {
+ client.close();
+ await server.stop();
+ });
+
+ group('normalizeCep', () {
+ test('accepts masked and unmasked', () {
+ for (final String value in <String>[
+ '01001000',
+ '01001-000',
+ ' 01001-000 ',
+ '01001.000',
+ ]) {
+ expect(normalizeCep(value), '01001000');
+ }
+ });
+
+ test('rejects a bad format', () {
+ for (final String value in <String>[
+ '',
+ '123',
+ '0100100a',
+ '010010000',
+ 'abcdefgh',
+ ]) {
+ expect(
+ () => normalizeCep(value),
+ throwsA(isA<InvalidCepException>()),
+ reason: value,
+ );
+ }
+ });
+
+ test('throws something a single handler can catch', () {
+ expect(() => normalizeCep('123'), throwsA(isA<CepisException>()));
+ });
+
+ test('hooks into FormatException', () {
+ expect(() => normalizeCep('123'), throwsFormatException);
+ });
+ });
+
+ group('lookup', () {
+ test('reads a successful lookup', () async {
+ server.routes.respond('/01001000', Reply(200, jsonEncode(seAddress)));
+
+ final address = await client.lookup('01001-000');
+ expect(address.localidade, 'São Paulo');
+ expect(address.uf, 'SP');
+ expect(address.logradouro, 'Praça da Sé');
+ expect(address.formattedCep, '01001-000');
+ expect(address.geo, isNull);
+ });
+
+ test('reads the coordinate when present', () async {
+ server.routes.respond(
+ '/01310100',
+ Reply(
+ 200,
+ jsonEncode(
+ withGeo(<String, dynamic>{
+ 'lat': -23.5,
+ 'lon': -46.6,
+ 'raio_m': 120,
+ 'precisao': 'logradouro',
+ }),
+ ),
+ ),
+ );
+
+ final address = await client.lookup('01310100');
+ expect(address.geo, isNotNull);
+ expect(address.geo!.lat, closeTo(-23.5, 1e-9));
+ expect(address.geo!.lon, closeTo(-46.6, 1e-9));
+ expect(address.geo!.raioM, 120);
+ expect(address.geo!.isPrecise, isTrue);
+ });
+
+ test('a municipality coordinate is not precise', () async {
+ server.routes.respond(
+ '/01001000',
+ Reply(
+ 200,
+ jsonEncode(
+ withGeo(<String, dynamic>{
+ 'lat': -23.5,
+ 'lon': -46.6,
+ 'raio_m': 22000,
+ 'precisao': 'localidade',
+ }),
+ ),
+ ),
+ );
+
+ final address = await client.lookup('01001000');
+ expect(address.geo!.isPrecise, isFalse);
+ expect(address.geo!.raioM, 22000);
+ });
+
+ test('a malformed geo block does not lose the address', () async {
+ server.routes.respond(
+ '/01001000',
+ Reply(
+ 200,
+ jsonEncode(withGeo(<String, dynamic>{'lat': 'not a number'})),
+ ),
+ );
+
+ final address = await client.lookup('01001000');
+ expect(address.geo, isNull);
+ expect(address.localidade, 'São Paulo');
+ });
+
+ test('a new API field does not break the client', () async {
+ server.routes.respond(
+ '/01001000',
+ Reply(
+ 200,
+ jsonEncode(<String, dynamic>{
+ ...seAddress,
+ 'campo_do_futuro': 'value',
+ }),
+ ),
+ );
+
+ final address = await client.lookup('01001000');
+ expect(address.raw['campo_do_futuro'], 'value');
+ expect(address.localidade, 'São Paulo');
+ });
+
+ 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',
+ }),
+ ),
+ );
+
+ await expectLater(
+ client.lookup('99999999'),
+ throwsA(
+ isA<CepNotFoundException>().having(
+ (CepNotFoundException e) => e.cep,
+ 'cep',
+ '99999999',
+ ),
+ ),
+ );
+ expect(server.routes.count('/99999999'), 1);
+ });
+
+ test('an invalid CEP never leaves the machine', () async {
+ await expectLater(
+ client.lookup('123'),
+ throwsA(isA<InvalidCepException>()),
+ );
+ expect(server.routes.calls, isEmpty);
+ });
+
+ test('400 becomes InvalidCepException carrying the API message', () async {
+ server.routes.respond(
+ '/00000000',
+ Reply(
+ 400,
+ jsonEncode(<String, String>{
+ 'erro': 'cep_invalido',
+ 'mensagem': 'rejected by the API',
+ }),
+ ),
+ );
+
+ await expectLater(
+ client.lookup('00000000'),
+ throwsA(
+ isA<InvalidCepException>().having(
+ (InvalidCepException e) => e.message,
+ 'message',
+ 'rejected by the API',
+ ),
+ ),
+ );
+ expect(server.routes.count('/00000000'), 1);
+ });
+
+ test('a response that is not JSON becomes InvalidResponseException',
+ () async {
+ server.routes
+ .respond('/01001000', const Reply(200, '<html>portal</html>'));
+
+ await expectLater(
+ client.lookup('01001000'),
+ throwsA(isA<InvalidResponseException>()),
+ );
+ });
+
+ test('a JSON array is not an address', () async {
+ server.routes.respond('/01001000', const Reply(200, '[]'));
+
+ await expectLater(
+ client.lookup('01001000'),
+ throwsA(isA<InvalidResponseException>()),
+ );
+ });
+
+ test('sends a User-Agent identifying the library', () async {
+ server.routes.respond('/01001000', Reply(200, jsonEncode(seAddress)));
+
+ await client.lookup('01001000');
+ expect(server.routes.userAgents.single, startsWith('cepis-dart/0.1.0'));
+ });
+ });
+
+ group('retrying', () {
+ test('retries a 500 and then succeeds', () async {
+ server.routes.respondInSequence('/01001000', <Reply>[
+ Reply(500, jsonEncode(<String, String>{'erro': 'interno'})),
+ Reply(200, jsonEncode(seAddress)),
+ ]);
+
+ final address = await client.lookup('01001000');
+ expect(address.localidade, 'São Paulo');
+ expect(server.routes.count('/01001000'), 2);
+ });
+
+ test('exhausts the attempts and then throws', () async {
+ server.routes.respond(
+ '/01001000',
+ Reply(503, jsonEncode(<String, String>{'erro': 'interno'})),
+ );
+
+ await expectLater(
+ client.lookup('01001000'),
+ throwsA(
+ isA<ServerException>().having(
+ (ServerException e) => e.status,
+ 'status',
+ 503,
+ ),
+ ),
+ );
+ expect(server.routes.count('/01001000'), 3);
+ });
+
+ test('maxAttempts of 1 disables retrying', () async {
+ final single = Client(baseUrl: server.url, maxAttempts: 1);
+ addTearDown(single.close);
+
+ server.routes.respond('/01001000', const Reply(500, '{}'));
+
+ await expectLater(
+ single.lookup('01001000'),
+ throwsA(isA<ServerException>()),
+ );
+ expect(server.routes.count('/01001000'), 1);
+ });
+
+ test('429 becomes RateLimitedException exposing Retry-After', () async {
+ server.routes.respond(
+ '/01001000',
+ const Reply(429, '{}', headers: <String, String>{'Retry-After': '7'}),
+ );
+
+ await expectLater(
+ client.lookup('01001000'),
+ throwsA(
+ isA<RateLimitedException>().having(
+ (RateLimitedException e) => e.retryAfter,
+ 'retryAfter',
+ const Duration(seconds: 7),
+ ),
+ ),
+ );
+ });
+
+ test('an HTTP-date Retry-After is understood', () async {
+ final when = HttpDate.format(
+ DateTime.now().add(const Duration(seconds: 4)),
+ );
+ server.routes.respond(
+ '/01001000',
+ Reply(429, '{}', headers: <String, String>{'Retry-After': when}),
+ );
+
+ await expectLater(
+ client.lookup('01001000'),
+ throwsA(
+ isA<RateLimitedException>().having(
+ (RateLimitedException e) => e.retryAfter,
+ 'retryAfter',
+ allOf(
+ greaterThan(Duration.zero),
+ lessThanOrEqualTo(const Duration(seconds: 5)),
+ ),
+ ),
+ ),
+ );
+ });
+
+ test('an absurd Retry-After is capped', () async {
+ server.routes.respond(
+ '/01001000',
+ const Reply(
+ 429,
+ '{}',
+ headers: <String, String>{'Retry-After': '3600'},
+ ),
+ );
+
+ final started = Stopwatch()..start();
+ await expectLater(
+ client.lookup('01001000'),
+ throwsA(isA<RateLimitedException>()),
+ );
+ expect(started.elapsed, lessThan(const Duration(seconds: 5)));
+ });
+
+ test('nothing listening becomes NetworkException', () async {
+ final unreachable = Client(
+ baseUrl: 'http://127.0.0.1:1',
+ maxAttempts: 2,
+ backoffBase: const Duration(milliseconds: 1),
+ timeout: const Duration(seconds: 2),
+ );
+ addTearDown(unreachable.close);
+
+ await expectLater(
+ unreachable.lookup('01001000'),
+ throwsA(isA<NetworkException>()),
+ );
+ });
+
+ test('totalTimeout bounds the whole call', () async {
+ final bounded = Client(
+ baseUrl: server.url,
+ maxAttempts: 50,
+ backoffBase: const Duration(milliseconds: 50),
+ backoffMax: const Duration(milliseconds: 50),
+ totalTimeout: const Duration(milliseconds: 300),
+ );
+ addTearDown(bounded.close);
+
+ server.routes.respond('/01001000', const Reply(500, '{}'));
+
+ final started = Stopwatch()..start();
+ await expectLater(
+ bounded.lookup('01001000'),
+ throwsA(isA<ServerException>()),
+ );
+ expect(started.elapsed, lessThan(const Duration(seconds: 5)));
+ expect(server.routes.count('/01001000'), lessThan(50));
+ });
+ });
+
+ group('lookupMany', () {
+ test('preserves order and marks a missing CEP', () async {
+ final ceps = <String>['01001000', '99999999', '20040020'];
+ server.routes.respond(
+ batchPath(ceps),
+ Reply(
+ 200,
+ csv(<String>[
+ '01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,'
+ 'Sudeste,3550308,1004,11,7107',
+ '99999999,,,,,,,,,,,,',
+ '20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,'
+ 'Rio de Janeiro,Sudeste,3304557,,21,6001',
+ ]),
+ ),
+ );
+
+ final result = await client.lookupMany(ceps);
+ expect(result, hasLength(3));
+ expect(result[0]!.localidade, 'São Paulo');
+ expect(result[1], isNull);
+ expect(result[2]!.uf, 'RJ');
+ expect(result[2]!.logradouro, 'Avenida Rio Branco');
+ });
+
+ test('a batch never carries a coordinate', () async {
+ final ceps = <String>['01001000'];
+ server.routes.respond(
+ batchPath(ceps),
+ Reply(
+ 200,
+ csv(<String>[
+ '01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,'
+ '3550308,1004,11,7107',
+ ]),
+ ),
+ );
+
+ final result = await client.lookupMany(ceps);
+ expect(result.single, isA<Address>());
+ expect(result.single!.geo, isNull);
+ });
+
+ test('a field containing a comma is parsed', () async {
+ final ceps = <String>['01001000'];
+ server.routes.respond(
+ batchPath(ceps),
+ Reply(
+ 200,
+ csv(<String>[
+ '01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,'
+ 'Sudeste,1,,11,1',
+ ]),
+ ),
+ );
+
+ final result = await client.lookupMany(ceps);
+ expect(result.single!.logradouro, 'Rua Um, Dois');
+ });
+
+ test('an empty input makes no request', () async {
+ expect(await client.lookupMany(<String>[]), isEmpty);
+ expect(server.routes.calls, isEmpty);
+ });
+
+ test('chunks above the API limit', () async {
+ final ceps = List<String>.generate(
+ 501,
+ (int i) => i.toString().padLeft(8, '0'),
+ );
+ server.routes.fallback = (String path) {
+ final requested =
+ Uri.parse('http://x$path').queryParameters['ceps'] ?? '';
+ final rows = requested
+ .split(',')
+ .map((String cep) => '$cep,,,,,Cidade,SP,São Paulo,Sudeste,1,,11,1')
+ .toList();
+ return Reply(200, csv(rows));
+ };
+
+ final result = await client.lookupMany(ceps);
+ expect(server.routes.calls, hasLength(2));
+ expect(result, hasLength(501));
+ expect(result[500]!.cep, '00000500');
+ });
+
+ test('a truncated response pads rather than shifts', () async {
+ final ceps = <String>['01001000', '20040020'];
+ server.routes.respond(
+ batchPath(ceps),
+ Reply(
+ 200,
+ csv(<String>['01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1']),
+ ),
+ );
+
+ final result = await client.lookupMany(ceps);
+ expect(result, hasLength(2));
+ expect(result[0]!.localidade, 'São Paulo');
+ expect(result[1], isNull);
+ });
+
+ test('a malformed CEP is null, not an error', () async {
+ final ceps = <String>['01001000', 'nope'];
+ server.routes.respond(
+ batchPath(ceps),
+ Reply(
+ 200,
+ csv(<String>[
+ '01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1',
+ 'nope,,,,,,,,,,,,',
+ ]),
+ ),
+ );
+
+ final result = await client.lookupMany(ceps);
+ expect(result[0], isNotNull);
+ expect(result[1], isNull);
+ });
+ });
+
+ group('health', () {
+ test('reads the report', () async {
+ server.routes.respond(
+ '/health',
+ Reply(
+ 200,
+ jsonEncode(<String, dynamic>{
+ 'status': 'ok',
+ 'dne': '26071',
+ 'ceps': 1596168,
+ 'geo': 894489,
+ 'base_gerada_em': '2026-07-24T03:48:28Z',
+ }),
+ ),
+ );
+
+ final report = await client.health();
+ expect(report.ok, isTrue);
+ expect(report.dne, '26071');
+ expect(report.ceps, 1596168);
+ expect(report.geo, 894489);
+ expect(report.baseGeradaEm, '2026-07-24T03:48:28Z');
+ });
+
+ test('a degraded API does not throw and is not retried', () async {
+ server.routes.respond(
+ '/health',
+ Reply(
+ 503,
+ jsonEncode(<String, dynamic>{
+ 'status': 'degradado',
+ 'dne': '',
+ 'ceps': 0,
+ 'geo': 0,
+ }),
+ ),
+ );
+
+ final report = await client.health();
+ expect(report.ok, isFalse);
+ expect(report.status, 'degradado');
+ expect(server.routes.count('/health'), 1);
+ });
+ });
+
+ group('configuration', () {
+ test('a trailing slash is not duplicated', () {
+ final trailing = Client(baseUrl: 'http://example.test/');
+ addTearDown(trailing.close);
+ expect(trailing.baseUrl, 'http://example.test');
+ });
+
+ test('rejects impossible settings', () {
+ expect(() => Client(maxAttempts: 0), throwsArgumentError);
+ expect(() => Client(timeout: Duration.zero), throwsArgumentError);
+ expect(
+ () => Client(totalTimeout: const Duration(seconds: -1)),
+ throwsArgumentError,
+ );
+ });
+
+ test('the default User-Agent identifies the library', () {
+ final plain = Client();
+ addTearDown(plain.close);
+ expect(plain.userAgent, startsWith('cepis-dart/'));
+ });
+ });
+
+ group('parseCsv', () {
+ test('handles quotes, escapes and CRLF', () {
+ expect(
+ parseCsv('a,b\r\n"x,1","he said ""hi"""\r\n'), <Map<String, String>>[
+ <String, String>{'a': 'x,1', 'b': 'he said "hi"'},
+ ]);
+ });
+
+ test('fills columns a short row does not have', () {
+ expect(parseCsv('a,b,c\n1\n'), <Map<String, String>>[
+ <String, String>{'a': '1', 'b': '', 'c': ''},
+ ]);
+ });
+
+ test('an empty input yields no records', () {
+ expect(parseCsv(''), isEmpty);
+ });
+ });
+
+ group('models', () {
+ test('two identical addresses are equal', () {
+ final one = Address.fromJson(Map<String, dynamic>.of(seAddress));
+ final two = Address.fromJson(<String, dynamic>{
+ ...seAddress,
+ 'campo_do_futuro': 'value',
+ });
+ expect(one, two);
+ expect(one.hashCode, two.hashCode);
+ });
+ });
+}
diff --git a/test/server.dart b/test/server.dart
new file mode 100644
index 0000000..b8f28de
--- /dev/null
+++ b/test/server.dart
@@ -0,0 +1,126 @@
+/// A throwaway HTTP server on a random loopback port.
+///
+/// The suite points a real `Client` at a real socket: no test touches the
+/// network, and no test stubs the HTTP layer. The code path exercised is the
+/// one that runs in production, request construction and error body reading
+/// included.
+///
+/// It doubles as the test that `baseUrl` works — if it did not, none of this
+/// would run.
+library;
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+/// What the fake server should answer for one request.
+class Reply {
+ /// Creates an answer with [status], [body] and any extra [headers].
+ const Reply(this.status, this.body,
+ {this.headers = const <String, String>{}});
+
+ /// HTTP status to answer with.
+ final int status;
+
+ /// Body to write, encoded as UTF-8.
+ final String body;
+
+ /// Extra response headers, such as `Retry-After`.
+ final Map<String, String> headers;
+}
+
+/// Per-test programmable routes, plus a record of what was asked.
+class Router {
+ final Map<String, List<Reply>> _routes = <String, List<Reply>>{};
+
+ /// Every path requested so far, in order, query string included.
+ final List<String> calls = <String>[];
+
+ /// The `User-Agent` of every request so far, in order.
+ final List<String> userAgents = <String>[];
+
+ /// Answers any path with no registered route.
+ Reply Function(String path)? fallback;
+
+ /// Registers the single answer for [path].
+ void respond(String path, Reply reply) {
+ _routes[path] = <Reply>[reply];
+ }
+
+ /// Registers a sequence of answers for [path], one per request.
+ ///
+ /// The last entry repeats once the sequence runs out, which is how retrying
+ /// is tested.
+ void respondInSequence(String path, List<Reply> replies) {
+ _routes[path] = List<Reply>.of(replies);
+ }
+
+ /// Records the call and returns the answer registered for [path].
+ Reply next(String path) {
+ calls.add(path);
+ final replies = _routes[path];
+ if (replies == null) {
+ final handler = fallback;
+ if (handler != null) {
+ return handler(path);
+ }
+ return Reply(
+ 404, jsonEncode(<String, String>{'erro': 'cep_nao_encontrado'}));
+ }
+ return replies.length > 1 ? replies.removeAt(0) : replies.first;
+ }
+
+ /// How many requests so far began with [prefix].
+ int count(String prefix) =>
+ calls.where((String call) => call.startsWith(prefix)).length;
+}
+
+/// A running fake API, with its router.
+class FakeServer {
+ FakeServer._(this._server, this.routes);
+
+ final HttpServer _server;
+
+ /// The routes this server answers from.
+ final Router routes;
+
+ /// Starts a fake API on `127.0.0.1` and a port the kernel picks.
+ static Future<FakeServer> start() async {
+ final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
+ final fake = FakeServer._(server, Router());
+ unawaited(fake._serve());
+ return fake;
+ }
+
+ /// Where to point a client's `baseUrl`.
+ String get url => 'http://127.0.0.1:${_server.port}';
+
+ /// Stops the server and drops any open connection.
+ Future<void> stop() => _server.close(force: true);
+
+ Future<void> _serve() async {
+ await for (final HttpRequest request in _server) {
+ await request.drain<void>();
+ routes.userAgents.add(
+ request.headers.value(HttpHeaders.userAgentHeader) ?? '',
+ );
+
+ final uri = request.uri;
+ final path = uri.query.isEmpty ? uri.path : '${uri.path}?${uri.query}';
+ final reply = routes.next(path);
+
+ final response = request.response
+ ..statusCode = reply.status
+ ..headers.contentType = ContentType(
+ 'application',
+ 'json',
+ charset: 'utf-8',
+ );
+ for (final MapEntry<String, String> header in reply.headers.entries) {
+ response.headers.set(header.key, header.value);
+ }
+ response.add(utf8.encode(reply.body));
+ await response.close();
+ }
+ }
+}