diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:38:22 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:38:22 -0300 |
| commit | 8e719fc715eb7ae9aa1c842e633f15b5c6cd3688 (patch) | |
| tree | 4afda3d13e1b772f2c9a5cf9ceea5e84c647ebdd | |
| download | dart-0.1.0.tar.gz | |
Initial importv0.1.0
| -rw-r--r-- | .gitignore | 9 | ||||
| -rw-r--r-- | CHANGELOG.md | 6 | ||||
| -rw-r--r-- | LICENSE | 15 | ||||
| -rw-r--r-- | Makefile | 38 | ||||
| -rw-r--r-- | README.md | 233 | ||||
| -rw-r--r-- | analysis_options.yaml | 15 | ||||
| -rw-r--r-- | bin/cepis.dart | 93 | ||||
| -rw-r--r-- | lib/cepis.dart | 51 | ||||
| -rw-r--r-- | lib/src/client.dart | 496 | ||||
| -rw-r--r-- | lib/src/csv.dart | 86 | ||||
| -rw-r--r-- | lib/src/errors.dart | 112 | ||||
| -rw-r--r-- | lib/src/models.dart | 363 | ||||
| -rw-r--r-- | lib/src/version.dart | 5 | ||||
| -rw-r--r-- | pubspec.yaml | 17 | ||||
| -rw-r--r-- | test/cepis_test.dart | 632 | ||||
| -rw-r--r-- | test/server.dart | 126 |
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. @@ -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(); + } + } +} |