diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:18:32 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:18:32 -0300 |
| commit | 72a4b451c9d06f7a97cac2f43f1b7f52fafcd3b7 (patch) | |
| tree | 3e4d33a614b673bc8cd9de1e688245f340cd3e1e | |
| download | swift-72a4b451c9d06f7a97cac2f43f1b7f52fafcd3b7.tar.gz | |
Initial importv0.1.0
| -rw-r--r-- | .gitignore | 7 | ||||
| -rw-r--r-- | .swift-format | 11 | ||||
| -rw-r--r-- | LICENSE | 15 | ||||
| -rw-r--r-- | Makefile | 43 | ||||
| -rw-r--r-- | Package.swift | 19 | ||||
| -rw-r--r-- | README.md | 208 | ||||
| -rw-r--r-- | Sources/Cepis/CSV.swift | 63 | ||||
| -rw-r--r-- | Sources/Cepis/Client.swift | 459 | ||||
| -rw-r--r-- | Sources/Cepis/Errors.swift | 82 | ||||
| -rw-r--r-- | Sources/Cepis/Models.swift | 241 | ||||
| -rw-r--r-- | Tests/CepisTests/CepisTests.swift | 459 | ||||
| -rw-r--r-- | Tests/CepisTests/TestHTTPServer.swift | 266 |
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 +} @@ -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) +} |