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 /lib/src | |
| download | dart-931a90c71726a2a149de11f290aed4c7ef5a8194.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'lib/src')
| -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 |
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'; |