diff options
Diffstat (limited to 'Sources/Cepis/Client.swift')
| -rw-r--r-- | Sources/Cepis/Client.swift | 459 |
1 files changed, 459 insertions, 0 deletions
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 + } +} |