summaryrefslogtreecommitdiffstats
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
downloadswift-957dd1847e2485df70cf64657959ed02c117db1b.tar.gz
Initial importv0.1.0
-rw-r--r--.gitignore7
-rw-r--r--.swift-format11
-rw-r--r--LICENSE15
-rw-r--r--Makefile43
-rw-r--r--Package.swift19
-rw-r--r--README.md208
-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
-rw-r--r--Tests/CepisTests/CepisTests.swift459
-rw-r--r--Tests/CepisTests/TestHTTPServer.swift266
12 files changed, 1873 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2ae5612
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.build/
+.swiftpm/
+Package.resolved
+*.xcodeproj/
+*.xcworkspace/
+DerivedData/
+.DS_Store
diff --git a/.swift-format b/.swift-format
new file mode 100644
index 0000000..61e7c20
--- /dev/null
+++ b/.swift-format
@@ -0,0 +1,11 @@
+{
+ "version": 1,
+ "lineLength": 100,
+ "indentation": {
+ "spaces": 4
+ },
+ "respectsExistingLineBreaks": true,
+ "lineBreakBeforeEachArgument": false,
+ "prioritizeKeepingFunctionOutputTogether": true,
+ "indentConditionalCompilationBlocks": false
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..344f4e3
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,43 @@
+SWIFT ?= swift
+CONFIGURATION ?= debug
+
+.DEFAULT_GOAL := help
+.PHONY: help test lint format build check clean
+
+help:
+ @echo 'Targets:'
+ @echo ' test run the test suite'
+ @echo ' lint swift-format lint, read-only'
+ @echo ' format apply swift-format in place (writes)'
+ @echo ' build compile the library (CONFIGURATION=$(CONFIGURATION))'
+ @echo ' check lint + test + build, in that order'
+ @echo ' clean remove build artefacts'
+ @echo
+ @echo 'Override the toolchain with e.g. make test SWIFT=/path/to/swift'
+ @echo 'Build a release with make build CONFIGURATION=release'
+
+test:
+ $(SWIFT) test
+
+lint:
+ @if $(SWIFT) format --version >/dev/null 2>&1; then \
+ $(SWIFT) format lint --strict --recursive Package.swift Sources Tests; \
+ else \
+ echo 'swift-format is not in this toolchain (needs Swift 6 or swift-format on PATH); skipping'; \
+ fi
+
+format:
+ @if $(SWIFT) format --version >/dev/null 2>&1; then \
+ $(SWIFT) format --in-place --recursive Package.swift Sources Tests; \
+ else \
+ echo 'swift-format is not in this toolchain (needs Swift 6 or swift-format on PATH); skipping'; \
+ fi
+
+build:
+ $(SWIFT) build -c $(CONFIGURATION)
+
+check: lint test build
+
+clean:
+ $(SWIFT) package clean
+ rm -rf .build
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..185f1d7
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,19 @@
+// swift-tools-version:5.9
+import PackageDescription
+
+let package = Package(
+ name: "Cepis",
+ platforms: [
+ .macOS(.v10_15),
+ .iOS(.v13),
+ .tvOS(.v13),
+ .watchOS(.v6),
+ ],
+ products: [
+ .library(name: "Cepis", targets: ["Cepis"])
+ ],
+ targets: [
+ .target(name: "Cepis"),
+ .testTarget(name: "CepisTests", dependencies: ["Cepis"]),
+ ]
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7d75a9c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,208 @@
+# Cepis
+
+Swift client for the [public CEP API at cep.is](https://cep.is). Official
+Correios data (DNE), refreshed every two weeks.
+
+```swift
+import Cepis
+
+let address = try await Client.shared.lookup("01001-000")
+print(address.logradouro, address.localidade, address.uf)
+// Praça da Sé São Paulo SP
+```
+
+No dependencies: `URLSession` and `Codable` from the standard library and
+Foundation.
+
+## Installation
+
+Add the package to `Package.swift`:
+
+```swift
+dependencies: [
+ .package(url: "https://git.cep.is/swift.git", from: "0.1.0")
+]
+```
+
+and the product to your target:
+
+```swift
+.target(name: "MyTarget", dependencies: [.product(name: "Cepis", package: "swift")])
+```
+
+Requires Swift 5.9 or newer. Runs on macOS 10.15+, iOS 13+ and Linux.
+
+## Usage
+
+### A single lookup
+
+`lookup` accepts the CEP masked or unmasked and returns an `Address`:
+
+```swift
+let a = try await Client.shared.lookup("01310-100")
+
+a.logradouro // "Avenida Paulista"
+a.localidade // "São Paulo"
+a.uf // "SP"
+a.ibge // "3550308"
+a.cep // "01310100" (always unmasked)
+a.formattedCEP // "01310-100"
+```
+
+The data fields keep the API's Portuguese names; only the casing follows Swift
+convention. Fields the database does not have for that CEP come back as empty
+strings, never `nil`.
+
+### Coordinates
+
+About 57% of CEPs have a coordinate. When they do not, `geo` is `nil`:
+
+```swift
+if let geo = a.geo {
+ print(geo.lat, geo.lon) // -23.567202 -46.649659
+ print(geo.raioM) // 1084 (JSON `raio_m`)
+ print(geo.precisao) // "logradouro"
+}
+```
+
+**Check `precisao` before computing distance.** There are two kinds of point,
+and the difference between them is tens of kilometres:
+
+| `precisao` | typical radius | what the point is |
+| --- | --- | --- |
+| `logradouro` | 110–140 m | the stretch of street — good for maps and distance |
+| `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city |
+
+`geo.isPrecise` is the shorthand for `precisao == "logradouro"`:
+
+```swift
+if let geo = a.geo, geo.isPrecise {
+ cost = shipping(from: geo.lat, geo.lon)
+} else {
+ cost = shipping(fromMunicipality: a.ibge)
+}
+```
+
+### Many CEPs at once
+
+Do not loop over `lookup`. Use `lookupMany`, which resolves up to 500 CEPs per
+request:
+
+```swift
+let ceps = ["01001000", "99999999", "20040020"]
+let result = try await Client.shared.lookupMany(ceps)
+
+for (requested, address) in zip(ceps, result) {
+ if let address {
+ print("\(requested): \(address.localidade)")
+ } else {
+ print("\(requested): not found")
+ }
+}
+```
+
+The returned array has **the same length and the same order** as the input. A
+CEP that does not exist, or is malformed, becomes `nil` at its position — it
+does not vanish, and it does not throw.
+
+Arrays longer than 500 are chunked automatically.
+
+> **Batches carry no coordinate.** The endpoint behind them is CSV, and the CSV
+> has no `geo` column. Every `Address` from `lookupMany` has `geo == nil`,
+> including for CEPs that do have one. Need `geo`? Use `lookup`, one at a time.
+
+### API health
+
+Useful for two things: checking the API answers, and knowing **which version of
+the database** is live:
+
+```swift
+let h = try await Client.shared.health()
+h.ok // true
+h.dne // "26071" identifier of the fortnightly database
+h.ceps // 1596168
+h.baseGeradaEm // "2026-07-24T03:48:28Z"
+```
+
+An API reporting itself degraded does not throw — that arrives in `h.status`.
+
+## Configuration
+
+For anything beyond the defaults, build a `Client` and reuse it. It is an
+immutable value and safe to share across tasks and threads:
+
+```swift
+let client = Client(
+ baseURL: URL(string: "https://cep.is")!, // point at another instance
+ timeout: 10, // seconds per request
+ maxAttempts: 3, // counting the first; 1 disables retrying
+ backoffBase: 0.5, // base of the exponential wait
+ backoffMax: 8, // cap on each wait, Retry-After included
+ totalTimeout: nil // cap on the whole call, attempts included
+)
+
+let address = try await client.lookup("01001000")
+```
+
+`timeout` is `URLRequest.timeoutInterval`, which measures inactivity rather
+than total elapsed time; `totalTimeout` is the hard cap on the whole call.
+
+Retries cover 429, 5xx and network failures, with exponential backoff and
+jitter; 400 and 404 are never retried.
+
+`Client.shared` is a ready-made instance with the defaults.
+
+### Pointing at a local instance
+
+`baseURL` is the only parameter that usually changes between environments. The
+API repo brings a local instance up with `just up`:
+
+```swift
+let client = Client(baseURL: URL(string: "http://localhost:8080")!)
+```
+
+## Errors
+
+Everything throws `CepisError`. Its cases split by **the decision you have to
+make**, not by the HTTP status that caused them:
+
+| case | when | does retrying help? |
+| --- | --- | --- |
+| `invalidCEP` | not 8 digits | no |
+| `cepNotFound` | well-formed, not in the database | no |
+| `rateLimited` | 429; carries `retryAfter` | yes, later |
+| `serverError` | 5xx; carries `status` | yes, later |
+| `networkError` | DNS, connection, timeout, TLS | yes, later |
+| `invalidResponse` | unintelligible response | probably not |
+
+```swift
+do {
+ let address = try await Client.shared.lookup(cepFromForm)
+} catch CepisError.cepNotFound {
+ // ask the user for another CEP
+} catch let error as CepisError {
+ // network, server, rate limit: reschedule
+ print(error.message, error.isRetriable)
+}
+```
+
+`invalidResponse` almost always 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.
+
+## Development
+
+```sh
+make # list the targets
+make test # run the suite
+make lint # swift-format lint, touches nothing
+make build # compile the library
+make check # lint, then test, then build
+```
+
+`lint` and `format` need `swift format` (Swift 6 toolchains) or `swift-format`
+on `PATH`; they report and skip when it is missing.
+
+## Licence
+
+ISC. See [LICENSE](LICENSE).
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" }
+}
diff --git a/Tests/CepisTests/CepisTests.swift b/Tests/CepisTests/CepisTests.swift
new file mode 100644
index 0000000..46699bc
--- /dev/null
+++ b/Tests/CepisTests/CepisTests.swift
@@ -0,0 +1,459 @@
+import Foundation
+import XCTest
+
+@testable import Cepis
+
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+let seAddressJSON = """
+ {"cep":"01001000","logradouro":"Praça da Sé","complemento":"lado ímpar",
+ "unidade":"","bairro":"Sé","localidade":"São Paulo","uf":"SP",
+ "estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004",
+ "ddd":"11","siafi":"7107"}
+ """
+
+let csvHeader =
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"
+
+func csvBody(_ rows: [String]) -> String {
+ ([csvHeader] + rows).joined(separator: "\n") + "\n"
+}
+
+class ServerBackedTestCase: XCTestCase {
+ var server: TestHTTPServer!
+ var client: Client!
+
+ var routes: Router { server.router }
+
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ server = try TestHTTPServer()
+ client = Client(
+ baseURL: server.baseURL,
+ timeout: 5,
+ backoffBase: 0.001,
+ backoffMax: 0.01
+ )
+ }
+
+ override func tearDown() {
+ server?.stop()
+ server = nil
+ client = nil
+ super.tearDown()
+ }
+}
+
+final class NormalisationTests: XCTestCase {
+ func testAcceptsMaskedAndUnmasked() throws {
+ for value in ["01001000", "01001-000", " 01001-000 ", "01001.000"] {
+ XCTAssertEqual(try normalizeCEP(value), "01001000")
+ }
+ }
+
+ func testRejectsBadFormat() {
+ for value in ["", "123", "0100100a", "010010000", "abcdefgh"] {
+ XCTAssertThrowsError(try normalizeCEP(value)) { error in
+ guard case CepisError.invalidCEP = error else {
+ return XCTFail("expected invalidCEP for \"\(value)\", got \(error)")
+ }
+ }
+ }
+ }
+
+ func testInvalidCEPIsNotRetriable() {
+ XCTAssertFalse(CepisError.invalidCEP("x").isRetriable)
+ XCTAssertFalse(CepisError.cepNotFound("x").isRetriable)
+ XCTAssertTrue(CepisError.networkError(message: "x").isRetriable)
+ }
+}
+
+final class LookupTests: ServerBackedTestCase {
+ func testSuccessfulLookup() async throws {
+ routes.respond("/01001000", body: seAddressJSON)
+ let address = try await client.lookup("01001-000")
+ XCTAssertEqual(address.localidade, "São Paulo")
+ XCTAssertEqual(address.logradouro, "Praça da Sé")
+ XCTAssertEqual(address.uf, "SP")
+ XCTAssertEqual(address.formattedCEP, "01001-000")
+ XCTAssertNil(address.geo)
+ }
+
+ func testReadsTheCoordinateWhenPresent() async throws {
+ routes.respond(
+ "/01310100",
+ body: """
+ {"cep":"01310100","logradouro":"Avenida Paulista","localidade":"São Paulo",
+ "uf":"SP","geo":{"lat":-23.5,"lon":-46.6,"raio_m":120,
+ "precisao":"logradouro"}}
+ """)
+ let address = try await client.lookup("01310100")
+ let geo = try XCTUnwrap(address.geo)
+ XCTAssertEqual(geo.lat, -23.5, accuracy: 0.0001)
+ XCTAssertEqual(geo.lon, -46.6, accuracy: 0.0001)
+ XCTAssertEqual(geo.raioM, 120)
+ XCTAssertTrue(geo.isPrecise)
+ }
+
+ func testMunicipalityCoordinateIsNotPrecise() async throws {
+ routes.respond(
+ "/01001000",
+ body: """
+ {"cep":"01001000","localidade":"São Paulo","geo":{"lat":-23.5,"lon":-46.6,
+ "raio_m":22000,"precisao":"localidade"}}
+ """)
+ let address = try await client.lookup("01001000")
+ let geo = try XCTUnwrap(address.geo)
+ XCTAssertFalse(geo.isPrecise)
+ XCTAssertEqual(geo.raioM, 22000)
+ }
+
+ func testMalformedGeoDoesNotLoseTheAddress() async throws {
+ routes.respond(
+ "/01001000",
+ body: #"{"cep":"01001000","localidade":"São Paulo","geo":{"lat":"not a number"}}"#)
+ let address = try await client.lookup("01001000")
+ XCTAssertNil(address.geo)
+ XCTAssertEqual(address.localidade, "São Paulo")
+ }
+
+ func testNullGeoDoesNotLoseTheAddress() async throws {
+ routes.respond(
+ "/01001000", body: #"{"cep":"01001000","localidade":"São Paulo","geo":null}"#)
+ let address = try await client.lookup("01001000")
+ XCTAssertNil(address.geo)
+ XCTAssertEqual(address.localidade, "São Paulo")
+ }
+
+ func testNewAPIFieldDoesNotBreak() async throws {
+ routes.respond(
+ "/01001000",
+ body: #"{"cep":"01001000","localidade":"São Paulo","campo_do_futuro":"value"}"#)
+ let address = try await client.lookup("01001000")
+ XCTAssertEqual(address.localidade, "São Paulo")
+ }
+
+ func testNotFound() async throws {
+ routes.respond(
+ "/99999999", status: 404,
+ body: #"{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}"#)
+ do {
+ _ = try await client.lookup("99999999")
+ XCTFail("expected cepNotFound")
+ } catch CepisError.cepNotFound(let message) {
+ XCTAssertTrue(message.contains("99999999"))
+ }
+ }
+
+ func testNotFoundIsNotRetried() async throws {
+ routes.respond("/99999999", status: 404, body: #"{"erro":"cep_nao_encontrado"}"#)
+ do {
+ _ = try await client.lookup("99999999")
+ XCTFail("expected cepNotFound")
+ } catch CepisError.cepNotFound {
+ }
+ XCTAssertEqual(routes.count("/99999999"), 1)
+ }
+
+ func testInvalidCEPNeverLeavesTheMachine() async throws {
+ do {
+ _ = try await client.lookup("123")
+ XCTFail("expected invalidCEP")
+ } catch CepisError.invalidCEP {
+ }
+ XCTAssertEqual(routes.calls.count, 0)
+ }
+
+ func testAPI400BecomesInvalidCEP() async throws {
+ routes.respond(
+ "/00000000", status: 400,
+ body: #"{"erro":"cep_invalido","mensagem":"rejected by the API"}"#)
+ do {
+ _ = try await client.lookup("00000000")
+ XCTFail("expected invalidCEP")
+ } catch CepisError.invalidCEP(let message) {
+ XCTAssertEqual(message, "rejected by the API")
+ }
+ }
+
+ func testResponseThatIsNotJSON() async throws {
+ routes.respond("/01001000", body: "<html>captive portal</html>")
+ do {
+ _ = try await client.lookup("01001000")
+ XCTFail("expected invalidResponse")
+ } catch CepisError.invalidResponse {
+ }
+ }
+}
+
+final class RetryTests: ServerBackedTestCase {
+ func testRetriesOn500ThenSucceeds() async throws {
+ routes.respond(
+ "/01001000",
+ sequence: [
+ StubResponse(status: 500, body: #"{"erro":"interno"}"#),
+ StubResponse(status: 200, body: seAddressJSON),
+ ])
+ let address = try await client.lookup("01001000")
+ XCTAssertEqual(address.localidade, "São Paulo")
+ XCTAssertEqual(routes.count("/01001000"), 2)
+ }
+
+ func testExhaustsAttemptsThenThrows() async throws {
+ routes.respond("/01001000", status: 503, body: #"{"erro":"interno"}"#)
+ do {
+ _ = try await client.lookup("01001000")
+ XCTFail("expected serverError")
+ } catch CepisError.serverError(_, let status) {
+ XCTAssertEqual(status, 503)
+ }
+ XCTAssertEqual(routes.count("/01001000"), 3)
+ }
+
+ func testMaxAttemptsOneDisablesRetry() async throws {
+ let single = Client(baseURL: server.baseURL, timeout: 5, maxAttempts: 1)
+ routes.respond("/01001000", status: 500, body: "{}")
+ do {
+ _ = try await single.lookup("01001000")
+ XCTFail("expected serverError")
+ } catch CepisError.serverError {
+ }
+ XCTAssertEqual(routes.count("/01001000"), 1)
+ }
+
+ func test429BecomesRateLimitedWithRetryAfter() async throws {
+ routes.respond("/01001000", status: 429, body: "{}", headers: ["Retry-After": "7"])
+ do {
+ _ = try await client.lookup("01001000")
+ XCTFail("expected rateLimited")
+ } catch CepisError.rateLimited(_, let retryAfter) {
+ XCTAssertEqual(retryAfter, 7)
+ }
+ }
+
+ func testRetryAfterHonoursTheCap() async throws {
+ routes.respond("/01001000", status: 429, body: "{}", headers: ["Retry-After": "3600"])
+ let started = Date()
+ do {
+ _ = try await client.lookup("01001000")
+ XCTFail("expected rateLimited")
+ } catch CepisError.rateLimited {
+ }
+ XCTAssertLessThan(Date().timeIntervalSince(started), 5)
+ }
+
+ func testRetryAfterAcceptsAnHTTPDate() throws {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(identifier: "GMT")
+ formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
+ let header = formatter.string(from: Date().addingTimeInterval(30))
+
+ let seconds = try XCTUnwrap(Client.retryAfterSeconds(header))
+ XCTAssertGreaterThan(seconds, 20)
+ XCTAssertLessThan(seconds, 40)
+ }
+
+ func testRetryAfterInThePastIsClampedToZero() {
+ XCTAssertEqual(Client.retryAfterSeconds("-5"), 0)
+ XCTAssertNil(Client.retryAfterSeconds(nil))
+ XCTAssertNil(Client.retryAfterSeconds("not a date"))
+ }
+
+ func testNetworkErrorWhenNothingListens() async throws {
+ let unreachable = Client(
+ baseURL: URL(string: "http://127.0.0.1:1")!,
+ timeout: 1,
+ maxAttempts: 2,
+ backoffBase: 0.001,
+ backoffMax: 0.01
+ )
+ do {
+ _ = try await unreachable.lookup("01001000")
+ XCTFail("expected networkError")
+ } catch CepisError.networkError {
+ }
+ }
+
+ func testTotalTimeoutBoundsTheWholeCall() async throws {
+ let bounded = Client(
+ baseURL: server.baseURL,
+ timeout: 5,
+ maxAttempts: 50,
+ backoffBase: 0.05,
+ backoffMax: 0.05,
+ totalTimeout: 0.3
+ )
+ routes.respond("/01001000", status: 500, body: "{}")
+ let started = Date()
+ do {
+ _ = try await bounded.lookup("01001000")
+ XCTFail("expected serverError")
+ } catch CepisError.serverError {
+ }
+ XCTAssertLessThan(Date().timeIntervalSince(started), 5)
+ XCTAssertLessThan(routes.count("/01001000"), 50)
+ }
+}
+
+final class BatchTests: ServerBackedTestCase {
+ func testPreservesOrderAndMarksMissing() async throws {
+ let body = csvBody([
+ "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,"
+ + "Sudeste,3550308,1004,11,7107",
+ "99999999,,,,,,,,,,,,",
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,"
+ + "Rio de Janeiro,Sudeste,3304557,,21,6001",
+ ])
+ routes.setHandler { _ in StubResponse(status: 200, body: body) }
+
+ let result = try await client.lookupMany(["01001000", "99999999", "20040020"])
+ XCTAssertEqual(result.count, 3)
+ XCTAssertEqual(result[0]?.localidade, "São Paulo")
+ XCTAssertNil(result[1])
+ XCTAssertEqual(result[2]?.uf, "RJ")
+ XCTAssertNil(result[0]?.geo)
+ }
+
+ func testFieldWithCommaIsParsed() async throws {
+ let body = csvBody([
+ #"01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1"#
+ ])
+ routes.setHandler { _ in StubResponse(status: 200, body: body) }
+
+ let result = try await client.lookupMany(["01001000"])
+ XCTAssertEqual(result[0]?.logradouro, "Rua Um, Dois")
+ }
+
+ func testEscapedQuotesInAField() async throws {
+ let body = csvBody([
+ #"01001000,"Rua ""Dois"" Irmãos",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1"#
+ ])
+ routes.setHandler { _ in StubResponse(status: 200, body: body) }
+
+ let result = try await client.lookupMany(["01001000"])
+ XCTAssertEqual(result[0]?.logradouro, #"Rua "Dois" Irmãos"#)
+ }
+
+ func testEmptyListMakesNoRequest() async throws {
+ let result = try await client.lookupMany([])
+ XCTAssertTrue(result.isEmpty)
+ XCTAssertEqual(routes.calls.count, 0)
+ }
+
+ func testChunksAboveTheAPILimit() async throws {
+ let ceps = (0..<501).map { String(format: "%08d", $0) }
+ let first = csvBody(ceps[0..<500].map { "\($0),,,,,X,SP,São Paulo,Sudeste,1,,11,1" })
+ let second = csvBody(ceps[500...].map { "\($0),,,,,X,SP,São Paulo,Sudeste,1,,11,1" })
+
+ let served = NSLock()
+ var count = 0
+ routes.setHandler { _ in
+ served.lock()
+ defer { served.unlock() }
+ count += 1
+ return StubResponse(status: 200, body: count == 1 ? first : second)
+ }
+
+ let result = try await client.lookupMany(ceps)
+ XCTAssertEqual(routes.count("/csv"), 2)
+ XCTAssertEqual(result.count, 501)
+ XCTAssertEqual(result[500]?.cep, "00000500")
+ }
+
+ func testTruncatedResponseDoesNotShift() async throws {
+ let body = csvBody(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"])
+ routes.setHandler { _ in StubResponse(status: 200, body: body) }
+
+ let result = try await client.lookupMany(["01001000", "20040020"])
+ XCTAssertEqual(result.count, 2)
+ XCTAssertEqual(result[0]?.localidade, "São Paulo")
+ XCTAssertNil(result[1])
+ }
+
+ func testMalformedCEPBecomesNilRatherThanThrowing() async throws {
+ let body = csvBody([
+ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1",
+ "abc,,,,,,,,,,,,",
+ ])
+ routes.setHandler { _ in StubResponse(status: 200, body: body) }
+
+ let result = try await client.lookupMany(["01001000", "abc"])
+ XCTAssertEqual(result.count, 2)
+ XCTAssertNil(result[1])
+ }
+}
+
+final class HealthTests: ServerBackedTestCase {
+ func testReadsHealth() async throws {
+ routes.respond(
+ "/health",
+ body: """
+ {"status":"ok","dne":"26071","ceps":1596168,"geo":894489,
+ "base_gerada_em":"2026-07-24T03:48:28Z"}
+ """)
+ let health = try await client.health()
+ XCTAssertTrue(health.ok)
+ XCTAssertEqual(health.dne, "26071")
+ XCTAssertEqual(health.ceps, 1_596_168)
+ XCTAssertEqual(health.geo, 894_489)
+ XCTAssertEqual(health.baseGeradaEm, "2026-07-24T03:48:28Z")
+ }
+
+ func testDegradedDoesNotThrow() async throws {
+ routes.respond(
+ "/health", status: 503,
+ body: #"{"status":"degradado","dne":"","ceps":0,"geo":0}"#)
+ let health = try await client.health()
+ XCTAssertFalse(health.ok)
+ XCTAssertEqual(health.status, "degradado")
+ XCTAssertEqual(routes.count("/health"), 1)
+ }
+}
+
+final class ConfigurationTests: XCTestCase {
+ func testTrailingSlashIsNotDuplicated() {
+ let client = Client(baseURL: URL(string: "http://example.test/")!)
+ XCTAssertEqual(client.baseURL.absoluteString, "http://example.test")
+ }
+
+ func testUserAgentIdentifiesTheLibrary() {
+ XCTAssertTrue(Client().userAgent.hasPrefix("cepis-swift/"))
+ XCTAssertTrue(Client().userAgent.contains(Client.version))
+ }
+
+ func testDefaultBaseURL() {
+ XCTAssertEqual(Client.defaultBaseURL.absoluteString, "https://cep.is")
+ XCTAssertEqual(Client.maxBatch, 500)
+ }
+
+ func testFormattedCEPLeavesOddLengthsAlone() {
+ XCTAssertEqual(Address(cep: "01001000").formattedCEP, "01001-000")
+ XCTAssertEqual(Address(cep: "123").formattedCEP, "123")
+ }
+}
+
+final class CSVTests: XCTestCase {
+ func testParsesQuotedFieldsAndCRLF() {
+ let rows = CSV.parse("a,b\r\n\"x,1\",\"y\"\"z\"\r\n")
+ XCTAssertEqual(rows.count, 2)
+ XCTAssertEqual(rows[0], ["a", "b"])
+ XCTAssertEqual(rows[1], ["x,1", "y\"z"])
+ }
+
+ func testParsesLastLineWithoutNewline() {
+ let rows = CSV.parse("a,b\n1,2")
+ XCTAssertEqual(rows.count, 2)
+ XCTAssertEqual(rows[1], ["1", "2"])
+ }
+
+ func testTrailingNewlineDoesNotProduceAnEmptyRow() {
+ XCTAssertEqual(CSV.parse("a,b\n1,2\n").count, 2)
+ }
+
+ func testEmptyInput() {
+ XCTAssertTrue(CSV.parse("").isEmpty)
+ }
+}
diff --git a/Tests/CepisTests/TestHTTPServer.swift b/Tests/CepisTests/TestHTTPServer.swift
new file mode 100644
index 0000000..66722d4
--- /dev/null
+++ b/Tests/CepisTests/TestHTTPServer.swift
@@ -0,0 +1,266 @@
+import Foundation
+
+#if canImport(Glibc)
+import Glibc
+private let socketStreamType = Int32(SOCK_STREAM.rawValue)
+private let sendFlags = Int32(MSG_NOSIGNAL)
+#elseif canImport(Darwin)
+import Darwin
+private let socketStreamType = SOCK_STREAM
+private let sendFlags: Int32 = 0
+#endif
+
+struct StubResponse {
+ var status: Int = 200
+ var body: String = ""
+ var headers: [String: String] = [:]
+}
+
+final class Router: @unchecked Sendable {
+ private let lock = NSLock()
+ private var routes: [String: [StubResponse]] = [:]
+ private var handler: ((String) -> StubResponse)?
+ private var recorded: [String] = []
+
+ func respond(_ path: String, status: Int = 200, body: String, headers: [String: String] = [:]) {
+ lock.lock()
+ defer { lock.unlock() }
+ routes[path] = [StubResponse(status: status, body: body, headers: headers)]
+ }
+
+ func respond(_ path: String, sequence: [StubResponse]) {
+ lock.lock()
+ defer { lock.unlock() }
+ routes[path] = sequence
+ }
+
+ func setHandler(_ handler: @escaping (String) -> StubResponse) {
+ lock.lock()
+ defer { lock.unlock() }
+ self.handler = handler
+ }
+
+ var calls: [String] {
+ lock.lock()
+ defer { lock.unlock() }
+ return recorded
+ }
+
+ func count(_ prefix: String) -> Int {
+ calls.filter { $0.hasPrefix(prefix) }.count
+ }
+
+ func next(_ path: String) -> StubResponse {
+ lock.lock()
+ defer { lock.unlock() }
+ recorded.append(path)
+
+ if let handler {
+ return handler(path)
+ }
+ guard var queued = routes[path], !queued.isEmpty else {
+ return StubResponse(status: 404, body: #"{"erro":"cep_nao_encontrado"}"#)
+ }
+ if queued.count > 1 {
+ let head = queued.removeFirst()
+ routes[path] = queued
+ return head
+ }
+ return queued[0]
+ }
+}
+
+/// A throwaway HTTP/1.1 server on a random loopback port, built on BSD
+/// sockets because Foundation ships no server on Linux.
+///
+/// Every response closes its connection, so there is no keep-alive state to
+/// track and every request is a fresh accept.
+final class TestHTTPServer: @unchecked Sendable {
+ let router = Router()
+ let port: UInt16
+
+ private let listener: Int32
+ private let stopped = NSLock()
+ private var isStopped = false
+
+ init() throws {
+ signal(SIGPIPE, SIG_IGN)
+
+ let opened = try TestHTTPServer.openListener()
+ listener = opened.descriptor
+ port = opened.port
+
+ let thread = Thread { [weak self] in self?.acceptLoop() }
+ thread.stackSize = 1 << 20
+ thread.start()
+ }
+
+ private static func openListener() throws -> (descriptor: Int32, port: UInt16) {
+ let descriptor = socket(AF_INET, socketStreamType, 0)
+ guard descriptor >= 0 else {
+ throw TestServerError.failed("socket() failed with errno \(errno)")
+ }
+
+ var reuse: Int32 = 1
+ setsockopt(
+ descriptor, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size))
+
+ var address = sockaddr_in()
+ address.sin_family = sa_family_t(AF_INET)
+ address.sin_port = in_port_t(UInt16(0).bigEndian)
+ address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
+
+ let bound = withUnsafePointer(to: &address) { pointer -> Int32 in
+ pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { generic in
+ bind(descriptor, generic, socklen_t(MemoryLayout<sockaddr_in>.size))
+ }
+ }
+ guard bound == 0 else {
+ close(descriptor)
+ throw TestServerError.failed("bind() failed with errno \(errno)")
+ }
+ guard listen(descriptor, 32) == 0 else {
+ close(descriptor)
+ throw TestServerError.failed("listen() failed with errno \(errno)")
+ }
+
+ var actual = sockaddr_in()
+ var length = socklen_t(MemoryLayout<sockaddr_in>.size)
+ let named = withUnsafeMutablePointer(to: &actual) { pointer -> Int32 in
+ pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { generic in
+ getsockname(descriptor, generic, &length)
+ }
+ }
+ guard named == 0 else {
+ close(descriptor)
+ throw TestServerError.failed("getsockname() failed with errno \(errno)")
+ }
+ return (descriptor, UInt16(bigEndian: actual.sin_port))
+ }
+
+ var baseURL: URL {
+ URL(string: "http://127.0.0.1:\(port)")!
+ }
+
+ func stop() {
+ stopped.lock()
+ if isStopped {
+ stopped.unlock()
+ return
+ }
+ isStopped = true
+ stopped.unlock()
+
+ shutdown(listener, Int32(SHUT_RDWR))
+ close(listener)
+ }
+
+ private var shuttingDown: Bool {
+ stopped.lock()
+ defer { stopped.unlock() }
+ return isStopped
+ }
+
+ private func acceptLoop() {
+ while !shuttingDown {
+ let client = accept(listener, nil, nil)
+ if client < 0 {
+ if errno == EINTR { continue }
+ return
+ }
+ let thread = Thread { [weak self] in self?.handle(client) }
+ thread.stackSize = 1 << 20
+ thread.start()
+ }
+ }
+
+ private func handle(_ client: Int32) {
+ defer { close(client) }
+
+ #if canImport(Darwin)
+ var noSigPipe: Int32 = 1
+ setsockopt(
+ client, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout<Int32>.size))
+ #endif
+
+ guard let path = readRequestPath(client) else { return }
+ let stub = router.next(path)
+ write(client, response(for: stub))
+ }
+
+ private func readRequestPath(_ client: Int32) -> String? {
+ var received: [UInt8] = []
+ var chunk = [UInt8](repeating: 0, count: 4096)
+
+ while headerEnd(of: received) == nil {
+ let read = recv(client, &chunk, chunk.count, 0)
+ if read <= 0 { break }
+ received.append(contentsOf: chunk[0..<read])
+ if received.count > 1 << 20 { break }
+ }
+ guard headerEnd(of: received) != nil else { return nil }
+
+ var lineEnd = received.count
+ for index in 0..<max(0, received.count - 1) where received[index] == 0x0D {
+ lineEnd = index
+ break
+ }
+ let line = String(decoding: received[0..<lineEnd], as: UTF8.self)
+ let parts = line.split(separator: " ")
+ guard parts.count >= 2 else { return nil }
+ return String(parts[1])
+ }
+
+ private func headerEnd(of bytes: [UInt8]) -> Int? {
+ guard bytes.count >= 4 else { return nil }
+ for index in 0...(bytes.count - 4) {
+ if bytes[index] == 0x0D, bytes[index + 1] == 0x0A, bytes[index + 2] == 0x0D,
+ bytes[index + 3] == 0x0A
+ {
+ return index
+ }
+ }
+ return nil
+ }
+
+ private func response(for stub: StubResponse) -> [UInt8] {
+ let body = Array(stub.body.utf8)
+ var head = "HTTP/1.1 \(stub.status) \(reason(stub.status))\r\n"
+ for (name, value) in stub.headers {
+ head += "\(name): \(value)\r\n"
+ }
+ head += "Content-Length: \(body.count)\r\n"
+ head += "Connection: close\r\n\r\n"
+ return Array(head.utf8) + body
+ }
+
+ private func reason(_ status: Int) -> String {
+ switch status {
+ case 200: return "OK"
+ case 400: return "Bad Request"
+ case 404: return "Not Found"
+ case 429: return "Too Many Requests"
+ case 500: return "Internal Server Error"
+ case 502: return "Bad Gateway"
+ case 503: return "Service Unavailable"
+ case 504: return "Gateway Timeout"
+ default: return "Status"
+ }
+ }
+
+ private func write(_ client: Int32, _ bytes: [UInt8]) {
+ var offset = 0
+ while offset < bytes.count {
+ let written = bytes.withUnsafeBufferPointer { buffer -> Int in
+ guard let base = buffer.baseAddress else { return -1 }
+ return send(client, base + offset, bytes.count - offset, sendFlags)
+ }
+ if written <= 0 { return }
+ offset += written
+ }
+ }
+}
+
+enum TestServerError: Error {
+ case failed(String)
+}