summaryrefslogtreecommitdiffstats
path: root/lib/src
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 /lib/src
downloaddart-8e719fc715eb7ae9aa1c842e633f15b5c6cd3688.tar.gz
Initial importv0.1.0
Diffstat (limited to 'lib/src')
-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
5 files changed, 1062 insertions, 0 deletions
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';