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 = [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.. 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 = [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) 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(_ 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 } }