summaryrefslogtreecommitdiffstats
path: root/Sources/Cepis/Client.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Sources/Cepis/Client.swift')
-rw-r--r--Sources/Cepis/Client.swift459
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
+ }
+}