summaryrefslogtreecommitdiffstats
path: root/Sources
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:18:32 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:18:32 -0300
commit72a4b451c9d06f7a97cac2f43f1b7f52fafcd3b7 (patch)
tree3e4d33a614b673bc8cd9de1e688245f340cd3e1e /Sources
downloadswift-72a4b451c9d06f7a97cac2f43f1b7f52fafcd3b7.tar.gz
Initial importv0.1.0
Diffstat (limited to 'Sources')
-rw-r--r--Sources/Cepis/CSV.swift63
-rw-r--r--Sources/Cepis/Client.swift459
-rw-r--r--Sources/Cepis/Errors.swift82
-rw-r--r--Sources/Cepis/Models.swift241
4 files changed, 845 insertions, 0 deletions
diff --git a/Sources/Cepis/CSV.swift b/Sources/Cepis/CSV.swift
new file mode 100644
index 0000000..064ed05
--- /dev/null
+++ b/Sources/Cepis/CSV.swift
@@ -0,0 +1,63 @@
+import Foundation
+
+enum CSV {
+ static func parse(_ text: String) -> [[String]] {
+ var rows: [[String]] = []
+ var row: [String] = []
+ var field = ""
+ var inQuotes = false
+
+ let scalars = Array(text.unicodeScalars)
+ var index = 0
+
+ while index < scalars.count {
+ let scalar = scalars[index]
+
+ if inQuotes {
+ if scalar == "\"" {
+ if index + 1 < scalars.count && scalars[index + 1] == "\"" {
+ field.unicodeScalars.append("\"")
+ index += 2
+ } else {
+ inQuotes = false
+ index += 1
+ }
+ } else {
+ field.unicodeScalars.append(scalar)
+ index += 1
+ }
+ continue
+ }
+
+ switch scalar {
+ case "\"":
+ inQuotes = true
+ index += 1
+ case ",":
+ row.append(field)
+ field = ""
+ index += 1
+ case "\r", "\n":
+ row.append(field)
+ field = ""
+ rows.append(row)
+ row = []
+ if scalar == "\r", index + 1 < scalars.count, scalars[index + 1] == "\n" {
+ index += 2
+ } else {
+ index += 1
+ }
+ default:
+ field.unicodeScalars.append(scalar)
+ index += 1
+ }
+ }
+
+ if !field.isEmpty || !row.isEmpty {
+ row.append(field)
+ rows.append(row)
+ }
+
+ return rows
+ }
+}
diff --git a/Sources/Cepis/Client.swift b/Sources/Cepis/Client.swift
new file mode 100644
index 0000000..e0d5135
--- /dev/null
+++ b/Sources/Cepis/Client.swift
@@ -0,0 +1,459 @@
+import Dispatch
+import Foundation
+
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+/// Returns the CEP's 8 digits, accepting the mask.
+///
+/// `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`.
+///
+/// Throws ``CepisError/invalidCEP(_:)`` unless exactly 8 digits remain. The
+/// client runs this **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.
+public func normalizeCEP(_ value: String) throws -> String {
+ var cleaned = ""
+ for character in value.trimmingCharacters(in: .whitespacesAndNewlines) {
+ if character == "-" || character == "." || character == " " { continue }
+ cleaned.append(character)
+ }
+ guard cleaned.count == 8, cleaned.allSatisfy({ $0.isASCII && $0.isNumber }) else {
+ throw CepisError.invalidCEP("CEP must be 8 digits, got \"\(value)\"")
+ }
+ return cleaned
+}
+
+private struct HTTPOutcome: Sendable {
+ let status: Int
+ let body: Data
+ let retryAfter: TimeInterval?
+}
+
+private final class TaskBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private var task: URLSessionDataTask?
+ private var cancelled = false
+
+ func store(_ task: URLSessionDataTask) {
+ lock.lock()
+ defer { lock.unlock() }
+ if cancelled {
+ task.cancel()
+ } else {
+ self.task = task
+ }
+ }
+
+ func cancel() {
+ lock.lock()
+ defer { lock.unlock() }
+ cancelled = true
+ task?.cancel()
+ }
+}
+
+/// Client for the cep.is API.
+///
+/// Build one and reuse it: the configuration is immutable and no method keeps
+/// state between calls, so it is safe to share across tasks and threads.
+///
+/// ```swift
+/// let client = Client(timeout: 5, totalTimeout: 15)
+/// let address = try await client.lookup("01001-000")
+/// print(address.localidade, address.uf)
+/// ```
+///
+/// ``Client/shared`` is a ready-made instance with the defaults, for scripts
+/// and one-off lookups.
+public struct Client: @unchecked Sendable {
+ /// Where the public API lives: `https://cep.is`.
+ public static let defaultBaseURL = URL(string: "https://cep.is")!
+
+ /// The most CEPs the API accepts in one batch request.
+ public static let maxBatch = 500
+
+ /// This library's version, as it appears in the default `User-Agent`.
+ public static let version = "0.1.0"
+
+ /// A shared client with the default configuration.
+ ///
+ /// It is immutable; anyone needing different settings builds their own.
+ public static let shared = Client()
+
+ static let retriableStatus: Set<Int> = [429, 500, 502, 503, 504]
+
+ /// Where the API lives. A trailing slash is stripped on construction, so
+ /// it cannot produce the doubled separators that some proxies normalise
+ /// and others 404.
+ public let baseURL: URL
+
+ /// Seconds allowed **per request**, as `URLRequest.timeoutInterval`
+ /// defines it — not a cap on the whole call. That cap is ``totalTimeout``.
+ public let timeout: TimeInterval
+
+ /// How many attempts in total, counting the first. `1` disables retrying.
+ public let maxAttempts: Int
+
+ /// Base of the exponential wait, in seconds.
+ public let backoffBase: TimeInterval
+
+ /// Cap on each individual wait, in seconds. It applies to a server-sent
+ /// `Retry-After` too.
+ public let backoffMax: TimeInterval
+
+ /// Cap on the whole call, attempts and waits included, or `nil` to leave
+ /// ``maxAttempts`` as the only bound.
+ public let totalTimeout: TimeInterval?
+
+ /// The `User-Agent` sent with every request.
+ public let userAgent: String
+
+ private let session: URLSession
+
+ /// Creates a client.
+ ///
+ /// - Parameters:
+ /// - baseURL: 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.
+ /// - timeout: Seconds allowed per request. This is `URLRequest`'s
+ /// timeout, which measures inactivity rather than total elapsed time;
+ /// for a hard cap on the call use `totalTimeout`.
+ /// - maxAttempts: How many attempts in total, counting the first. The
+ /// default of `3` covers the common case (one transient failure)
+ /// without turning a real outage into a long wait.
+ /// - backoffBase: Base of the exponential wait, in seconds. 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: Cap on each individual wait, in seconds. 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: Cap on the whole call, attempts and waits included.
+ /// Set it in code serving a web request: without it the worst case is
+ /// `maxAttempts * timeout` plus the sum of the backoffs.
+ /// - userAgent: Overrides the `User-Agent`. The default identifies the
+ /// library and its version, which is what lets the server diagnose an
+ /// old client misbehaving.
+ /// - session: The `URLSession` to send through. The shared session is
+ /// the right answer almost always.
+ ///
+ /// - Precondition: `maxAttempts >= 1` and `timeout > 0`.
+ public init(
+ baseURL: URL = Client.defaultBaseURL,
+ timeout: TimeInterval = 10,
+ maxAttempts: Int = 3,
+ backoffBase: TimeInterval = 0.5,
+ backoffMax: TimeInterval = 8,
+ totalTimeout: TimeInterval? = nil,
+ userAgent: String? = nil,
+ session: URLSession = .shared
+ ) {
+ precondition(maxAttempts >= 1, "maxAttempts must be >= 1")
+ precondition(timeout > 0, "timeout must be > 0")
+
+ self.baseURL = Client.stripTrailingSlashes(baseURL)
+ self.timeout = timeout
+ self.maxAttempts = maxAttempts
+ self.backoffBase = backoffBase
+ self.backoffMax = backoffMax
+ self.totalTimeout = totalTimeout
+ self.userAgent = userAgent ?? "cepis-swift/\(Client.version) (+https://cep.is)"
+ self.session = session
+ }
+
+ /// Looks up a single CEP.
+ ///
+ /// ```swift
+ /// let address = try await client.lookup("01001-000")
+ /// address.localidade // "São Paulo"
+ /// ```
+ ///
+ /// A 400 from the API surfaces as ``CepisError/invalidCEP(_:)`` carrying
+ /// the API's own message: the format already passed local validation, so
+ /// the server knows something this client does not.
+ ///
+ /// - Throws: ``CepisError/invalidCEP(_:)`` when the CEP is not 8 digits,
+ /// before any request is made; ``CepisError/cepNotFound(_:)`` when it is
+ /// well-formed but absent from the database;
+ /// ``CepisError/rateLimited(message:retryAfter:)``,
+ /// ``CepisError/serverError(message:status:)`` or
+ /// ``CepisError/networkError(message:)`` once every attempt has failed.
+ public func lookup(_ cep: String) async throws -> Address {
+ let number = try normalizeCEP(cep)
+ let response = try await perform(path: "/\(number)")
+
+ if response.status == 404 {
+ throw CepisError.cepNotFound("CEP \(number) does not exist in the DNE database")
+ }
+ if response.status == 400 {
+ throw CepisError.invalidCEP(
+ Client.apiMessage(response.body, fallback: "CEP \(number) rejected"))
+ }
+
+ return try Client.decode(Address.self, from: response.body)
+ }
+
+ /// Looks up many CEPs in one batch.
+ ///
+ /// Returns an array of **the same length and in the same order** as the
+ /// input. A CEP that does not exist becomes `nil` at its position — it does
+ /// not vanish from the array, 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 `nil`, for the same reason. If you need to
+ /// know *which* one was malformed, validate first with ``normalizeCEP(_:)``.
+ ///
+ /// Input is chunked into blocks of ``maxBatch`` automatically.
+ ///
+ /// - Warning: The batch carries **no coordinate**. The endpoint behind it
+ /// is CSV, and the CSV has no `geo` column. Every ``Address`` returned
+ /// here has `geo == nil`, including for CEPs that do have one. Need the
+ /// coordinate? Use ``lookup(_:)``, one at a time.
+ public func lookupMany(_ ceps: [String]) async throws -> [Address?] {
+ guard !ceps.isEmpty else { return [] }
+
+ var result: [Address?] = []
+ result.reserveCapacity(ceps.count)
+
+ var start = 0
+ while start < ceps.count {
+ let end = min(start + Client.maxBatch, ceps.count)
+ result.append(contentsOf: try await lookupChunk(Array(ceps[start..<end])))
+ 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 error. Network failures still throw.
+ public func health() async throws -> Health {
+ let response = try await perform(path: "/health", acceptedStatus: [200, 503])
+ return try Client.decode(Health.self, from: response.body)
+ }
+
+ private func lookupChunk(_ ceps: [String]) async throws -> [Address?] {
+ let joined = ceps.joined(separator: ",")
+ let encoded = joined.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? joined
+ let response = try await perform(path: "/csv", query: "ceps=\(encoded)")
+
+ var rows = Client.addresses(fromCSV: Client.text(from: response.body))
+
+ if rows.count > ceps.count {
+ rows = Array(rows.prefix(ceps.count))
+ } else if rows.count < ceps.count {
+ rows.append(contentsOf: [Address?](repeating: nil, count: ceps.count - rows.count))
+ }
+ return rows
+ }
+
+ private func perform(
+ path: String,
+ query: String? = nil,
+ acceptedStatus: Set<Int> = [200]
+ ) async throws -> HTTPOutcome {
+ let url = try makeURL(path: path, query: query)
+ let deadline = totalTimeout.map { Client.monotonicNow() + $0 }
+
+ var lastStatus: Int?
+ var lastRetryAfter: TimeInterval?
+ var lastFailure: String?
+
+ var attempt = 1
+ while attempt <= maxAttempts {
+ if let deadline, Client.monotonicNow() >= deadline { break }
+
+ do {
+ let outcome = try await send(makeRequest(url: url))
+ if acceptedStatus.contains(outcome.status)
+ || !Client.retriableStatus.contains(outcome.status)
+ {
+ return outcome
+ }
+ lastStatus = outcome.status
+ lastRetryAfter = outcome.retryAfter
+ lastFailure = nil
+ } catch let error as CepisError {
+ throw error
+ } catch {
+ if Task.isCancelled { throw CancellationError() }
+ lastStatus = nil
+ lastRetryAfter = nil
+ lastFailure = "\(error)"
+ }
+
+ if attempt == maxAttempts { break }
+
+ var wait = backoff(attempt: attempt, retryAfter: lastRetryAfter)
+ if let deadline {
+ let remaining = deadline - Client.monotonicNow()
+ if remaining <= 0 { break }
+ wait = min(wait, remaining)
+ }
+ if wait > 0 {
+ try await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
+ }
+ attempt += 1
+ }
+
+ let target = "\(url.absoluteString) (after \(maxAttempts) attempt(s))"
+ if lastStatus == 429 {
+ throw CepisError.rateLimited(
+ message: "rate limit reached at \(target)", retryAfter: lastRetryAfter)
+ }
+ if let status = lastStatus {
+ throw CepisError.serverError(
+ message: "the API answered \(status) at \(target)", status: status)
+ }
+ throw CepisError.networkError(
+ message: "could not reach \(target): "
+ + (lastFailure ?? "the deadline set by totalTimeout elapsed"))
+ }
+
+ private func makeRequest(url: URL) -> URLRequest {
+ var request = URLRequest(url: url)
+ request.httpMethod = "GET"
+ request.timeoutInterval = timeout
+ request.cachePolicy = .reloadIgnoringLocalCacheData
+ request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
+ request.setValue(
+ "application/json, text/csv;q=0.9, */*;q=0.1", forHTTPHeaderField: "Accept")
+ request.setValue("identity", forHTTPHeaderField: "Accept-Encoding")
+ return request
+ }
+
+ private func send(_ request: URLRequest) async throws -> HTTPOutcome {
+ let box = TaskBox()
+ return try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation {
+ (continuation: CheckedContinuation<HTTPOutcome, Error>) in
+ let task = session.dataTask(with: request) { data, response, error in
+ if let error {
+ continuation.resume(throwing: error)
+ return
+ }
+ guard let http = response as? HTTPURLResponse else {
+ continuation.resume(
+ throwing: CepisError.invalidResponse(
+ "the answer did not come back as HTTP"))
+ return
+ }
+ continuation.resume(
+ returning: HTTPOutcome(
+ status: http.statusCode,
+ body: data ?? Data(),
+ retryAfter: Client.retryAfterSeconds(
+ http.value(forHTTPHeaderField: "Retry-After"))))
+ }
+ box.store(task)
+ task.resume()
+ }
+ } onCancel: {
+ box.cancel()
+ }
+ }
+
+ private func backoff(attempt: Int, retryAfter: TimeInterval?) -> TimeInterval {
+ if let retryAfter { return min(retryAfter, backoffMax) }
+ let ceiling = min(backoffBase * pow(2, Double(attempt - 1)), backoffMax)
+ guard ceiling > 0 else { return 0 }
+ return Double.random(in: 0...ceiling)
+ }
+
+ private func makeURL(path: String, query: String?) throws -> URL {
+ var text = baseURL.absoluteString + path
+ if let query { text += "?\(query)" }
+ guard let url = URL(string: text) else {
+ throw CepisError.invalidResponse("could not build a request URL from \"\(text)\"")
+ }
+ return url
+ }
+
+ private static func stripTrailingSlashes(_ url: URL) -> URL {
+ var text = url.absoluteString
+ while text.hasSuffix("/") { text.removeLast() }
+ return URL(string: text) ?? url
+ }
+
+ private static func monotonicNow() -> TimeInterval {
+ Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000
+ }
+
+ static func retryAfterSeconds(_ value: String?) -> TimeInterval? {
+ guard let raw = value?.trimmingCharacters(in: .whitespaces), !raw.isEmpty else {
+ return nil
+ }
+ if let seconds = Double(raw) { return max(0, seconds) }
+
+ let formats = [
+ "EEE, dd MMM yyyy HH:mm:ss zzz",
+ "EEEE, dd-MMM-yy HH:mm:ss zzz",
+ "EEE MMM d HH:mm:ss yyyy",
+ ]
+ for format in formats {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(identifier: "GMT")
+ formatter.dateFormat = format
+ if let date = formatter.date(from: raw) {
+ return max(0, date.timeIntervalSinceNow)
+ }
+ }
+ return nil
+ }
+
+ private static func decode<T: Decodable>(_ type: T.Type, from body: Data) throws -> T {
+ do {
+ return try JSONDecoder().decode(type, from: body)
+ } catch {
+ let preview = String(decoding: body.prefix(120), as: UTF8.self)
+ throw CepisError.invalidResponse(
+ "response is not valid JSON — something between you and the API is "
+ + "answering instead of it: \"\(preview)\"")
+ }
+ }
+
+ private static func apiMessage(_ body: Data, fallback: String) -> String {
+ struct APIError: Decodable {
+ let mensagem: String?
+ }
+ if let decoded = try? JSONDecoder().decode(APIError.self, from: body),
+ let message = decoded.mensagem, !message.isEmpty
+ {
+ return message
+ }
+ return fallback
+ }
+
+ private static func text(from body: Data) -> String {
+ var bytes = body
+ if bytes.starts(with: [0xEF, 0xBB, 0xBF]) {
+ bytes = bytes.dropFirst(3)
+ }
+ return String(decoding: bytes, as: UTF8.self)
+ }
+
+ static func addresses(fromCSV text: String) -> [Address?] {
+ let rows = CSV.parse(text)
+ guard let header = rows.first else { return [] }
+
+ var result: [Address?] = []
+ for row in rows.dropFirst() {
+ var fields: [String: String] = [:]
+ for (index, name) in header.enumerated() {
+ let key = name.trimmingCharacters(in: .whitespaces)
+ fields[key] = index < row.count ? row[index] : ""
+ }
+ let localidade = (fields["localidade"] ?? "").trimmingCharacters(in: .whitespaces)
+ result.append(localidade.isEmpty ? nil : Address(csvFields: fields))
+ }
+ return result
+ }
+}
diff --git a/Sources/Cepis/Errors.swift b/Sources/Cepis/Errors.swift
new file mode 100644
index 0000000..1472f81
--- /dev/null
+++ b/Sources/Cepis/Errors.swift
@@ -0,0 +1,82 @@
+import Foundation
+
+/// Every failure this library produces.
+///
+/// One type, so `catch let error as CepisError` handles anything that can go
+/// wrong. The cases split by **the decision the caller has to make**, not by
+/// the HTTP status that caused them:
+///
+/// - ``invalidCEP(_:)`` and ``cepNotFound(_:)`` are problems with the
+/// *request*. Retrying is pointless — the same CEP will fail again.
+/// - ``networkError(message:)``, ``serverError(message:status:)`` and
+/// ``rateLimited(message:retryAfter:)`` are problems with *transport*.
+/// Retrying may help, and the client already retried on its own before
+/// throwing any of them.
+///
+/// That split is why ``cepNotFound(_:)`` is not "the HTTP 404 case": nothing
+/// about it is tied to a status line.
+public enum CepisError: Error, Sendable {
+ /// The CEP is not 8 digits.
+ ///
+ /// Usually thrown **without** a request ever being made — the client
+ /// checks the format before going to the network. Also thrown when the
+ /// API itself answers 400, in which case the payload carries the API's own
+ /// message.
+ case invalidCEP(String)
+
+ /// The CEP is well-formed but does not exist in the DNE database.
+ case cepNotFound(String)
+
+ /// The API (or the edge in front of it) answered 429 on every attempt.
+ ///
+ /// `retryAfter` is what the server asked for, in seconds, or `nil` if it
+ /// did not say. The client already honoured that value across the attempts
+ /// it made; it is exposed here for callers who want to reschedule the work.
+ case rateLimited(message: String, retryAfter: TimeInterval?)
+
+ /// The API answered 5xx on every attempt. `status` is the last one seen.
+ case serverError(message: String, status: Int)
+
+ /// The request never completed: DNS, refused connection, timeout, TLS.
+ ///
+ /// Thrown only after every configured attempt has failed.
+ case networkError(message: String)
+
+ /// 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.
+ case invalidResponse(String)
+
+ /// The human-readable message carried by the case.
+ public var message: String {
+ switch self {
+ case .invalidCEP(let message): return message
+ case .cepNotFound(let message): return message
+ case .rateLimited(let message, _): return message
+ case .serverError(let message, _): return message
+ case .networkError(let message): return message
+ case .invalidResponse(let message): return message
+ }
+ }
+
+ /// Whether making the same call again later stands a chance of succeeding.
+ ///
+ /// `true` for rate limiting, server errors and network failures; `false`
+ /// for a malformed or absent CEP, which will fail identically forever.
+ public var isRetriable: Bool {
+ switch self {
+ case .rateLimited, .serverError, .networkError: return true
+ case .invalidCEP, .cepNotFound, .invalidResponse: return false
+ }
+ }
+}
+
+extension CepisError: CustomStringConvertible {
+ public var description: String { message }
+}
+
+extension CepisError: LocalizedError {
+ public var errorDescription: String? { message }
+}
diff --git a/Sources/Cepis/Models.swift b/Sources/Cepis/Models.swift
new file mode 100644
index 0000000..8d8df35
--- /dev/null
+++ b/Sources/Cepis/Models.swift
@@ -0,0 +1,241 @@
+import Foundation
+
+/// 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`` 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.
+///
+/// ``raioM`` is the radius containing 90% of that CEP's addresses. It exists
+/// precisely so that error cannot pass unnoticed.
+///
+/// The property names are the API's own, in Portuguese; only the casing is
+/// adapted, and ``raioM`` maps to the JSON's `raio_m`.
+public struct Geo: Codable, Sendable, Equatable, Hashable {
+ /// Latitude in decimal degrees, WGS 84.
+ public let lat: Double
+ /// Longitude in decimal degrees, WGS 84.
+ public let lon: Double
+ /// Radius in metres containing 90% of the CEP's addresses. JSON `raio_m`.
+ public let raioM: Int
+ /// `"logradouro"` (a street) or `"localidade"` (a municipality centroid).
+ public let precisao: String
+
+ enum CodingKeys: String, CodingKey {
+ case lat
+ case lon
+ case raioM = "raio_m"
+ case precisao
+ }
+
+ public init(lat: Double, lon: Double, raioM: Int, precisao: String) {
+ self.lat = lat
+ self.lon = lon
+ self.raioM = raioM
+ self.precisao = precisao
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.lat = try container.decode(Double.self, forKey: .lat)
+ self.lon = try container.decode(Double.self, forKey: .lon)
+ if let exact = try? container.decode(Int.self, forKey: .raioM) {
+ self.raioM = exact
+ } else {
+ self.raioM = Int(try container.decode(Double.self, forKey: .raioM))
+ }
+ self.precisao = try container.decode(String.self, forKey: .precisao)
+ }
+
+ /// `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.
+ public var isPrecise: Bool { precisao == "logradouro" }
+}
+
+/// A CEP and everything the database knows about it.
+///
+/// Fields the database does not have for that CEP come back as empty strings,
+/// never `nil` — 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 `nil` 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.
+///
+/// The property names are the API's own, in Portuguese. Everything else in
+/// this library is English; the fields are the one deliberate exception, so
+/// that what you read in the JSON at <https://cep.is/01001000> is exactly what
+/// you access on the value.
+public struct Address: Codable, Sendable, Equatable, Hashable {
+ /// The CEP, always unmasked: `"01001000"`. See ``formattedCEP``.
+ public let cep: String
+ public let logradouro: String
+ public let complemento: String
+ public let unidade: String
+ public let bairro: String
+ public let localidade: String
+ /// The two-letter state code: `"SP"`.
+ public let uf: String
+ public let estado: String
+ public let regiao: String
+ /// The IBGE municipality code, the key to join against census data.
+ public let ibge: String
+ public let gia: String
+ public let ddd: String
+ public let siafi: String
+ /// The coordinate, or `nil` when the database has none for this CEP.
+ ///
+ /// Always `nil` for an ``Address`` obtained from
+ /// ``Client/lookupMany(_:)`` — batches carry no coordinate at all.
+ public let geo: Geo?
+
+ enum CodingKeys: String, CodingKey {
+ case cep, logradouro, complemento, unidade, bairro, localidade
+ case uf, estado, regiao, ibge, gia, ddd, siafi, geo
+ }
+
+ public init(
+ cep: String,
+ logradouro: String = "",
+ complemento: String = "",
+ unidade: String = "",
+ bairro: String = "",
+ localidade: String = "",
+ uf: String = "",
+ estado: String = "",
+ regiao: String = "",
+ ibge: String = "",
+ gia: String = "",
+ ddd: String = "",
+ siafi: String = "",
+ geo: Geo? = nil
+ ) {
+ self.cep = cep
+ self.logradouro = logradouro
+ self.complemento = complemento
+ self.unidade = unidade
+ self.bairro = bairro
+ self.localidade = localidade
+ self.uf = uf
+ self.estado = estado
+ self.regiao = regiao
+ self.ibge = ibge
+ self.gia = gia
+ self.ddd = ddd
+ self.siafi = siafi
+ self.geo = geo
+ }
+
+ /// Decodes the API's JSON object.
+ ///
+ /// Unknown fields are ignored, so a field the API grows before this library
+ /// is updated cannot break an existing client. A malformed `geo` block
+ /// yields `geo == nil` rather than failing the whole decode: it is not
+ /// worth losing the address over, since the rest of the response is still
+ /// useful.
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ func string(_ key: CodingKeys) -> String {
+ (try? container.decode(String.self, forKey: key)) ?? ""
+ }
+
+ self.cep = string(.cep)
+ self.logradouro = string(.logradouro)
+ self.complemento = string(.complemento)
+ self.unidade = string(.unidade)
+ self.bairro = string(.bairro)
+ self.localidade = string(.localidade)
+ self.uf = string(.uf)
+ self.estado = string(.estado)
+ self.regiao = string(.regiao)
+ self.ibge = string(.ibge)
+ self.gia = string(.gia)
+ self.ddd = string(.ddd)
+ self.siafi = string(.siafi)
+ self.geo = (try? container.decodeIfPresent(Geo.self, forKey: .geo)) ?? nil
+ }
+
+ /// 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 library.
+ public var formattedCEP: String {
+ guard cep.count == 8 else { return cep }
+ let split = cep.index(cep.startIndex, offsetBy: 5)
+ return "\(cep[cep.startIndex..<split])-\(cep[split...])"
+ }
+
+ init(csvFields fields: [String: String]) {
+ self.init(
+ cep: fields["cep"] ?? "",
+ logradouro: fields["logradouro"] ?? "",
+ complemento: fields["complemento"] ?? "",
+ unidade: fields["unidade"] ?? "",
+ bairro: fields["bairro"] ?? "",
+ localidade: fields["localidade"] ?? "",
+ uf: fields["uf"] ?? "",
+ estado: fields["estado"] ?? "",
+ regiao: fields["regiao"] ?? "",
+ ibge: fields["ibge"] ?? "",
+ gia: fields["gia"] ?? "",
+ ddd: fields["ddd"] ?? "",
+ siafi: fields["siafi"] ?? "",
+ geo: nil
+ )
+ }
+}
+
+/// 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.
+public struct Health: Codable, Sendable, Equatable, Hashable {
+ /// `"ok"` when healthy. See ``ok``.
+ public let status: String
+ /// Identifier of the fortnightly DNE database currently loaded.
+ public let dne: String
+ /// How many CEPs the database holds.
+ public let ceps: Int
+ /// How many of those carry a coordinate.
+ public let geo: Int
+ /// When the database was generated, as an RFC 3339 timestamp.
+ public let baseGeradaEm: String
+
+ enum CodingKeys: String, CodingKey {
+ case status, dne, ceps, geo
+ case baseGeradaEm = "base_gerada_em"
+ }
+
+ public init(status: String, dne: String, ceps: Int, geo: Int, baseGeradaEm: String) {
+ self.status = status
+ self.dne = dne
+ self.ceps = ceps
+ self.geo = geo
+ self.baseGeradaEm = baseGeradaEm
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.status = (try? container.decode(String.self, forKey: .status)) ?? ""
+ self.dne = (try? container.decode(String.self, forKey: .dne)) ?? ""
+ self.ceps = (try? container.decode(Int.self, forKey: .ceps)) ?? 0
+ self.geo = (try? container.decode(Int.self, forKey: .geo)) ?? 0
+ self.baseGeradaEm = (try? container.decode(String.self, forKey: .baseGeradaEm)) ?? ""
+ }
+
+ /// `true` when the API reports itself healthy.
+ public var ok: Bool { status == "ok" }
+}