# 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.2.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; carries `faixa` | 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. ### Which city the missing CEP belonged to A CEP that fails is rarely a random number. It is usually someone who knows the city and guessed the digits — and the classic guess is the `-000`, which 957 municipalities never received. So `cepNotFound` carries a `Faixa`: the municipality the CEP *would* have belonged to, when the API can say. ```swift do { let address = try await Client.shared.lookup("25845-000") } catch CepisError.cepNotFound(let message, let faixa) { if let faixa { print("\(message) — that range is \(faixa.localidadeUF)") // … does not exist in the DNE database — that range is Areal/RJ faixa.ibge // "3300225" faixa.cepIni // "25845001" faixa.cepFim // "25849899" faixa.ceps // 118 } } ``` `error.faixa` reaches the same value without pattern-matching, and `error.description` (and `errorDescription`) already names the municipality when there is one. `error.message` stays exactly what it was. Three things to keep straight: - **It is still a 404.** The CEP does not exist. A `Faixa` is context for the error, never an address — it can turn *"not found"* into *"not a CEP, but you are looking at Areal/RJ"*, and nothing more. - **`cepIni`/`cepFim` are not the Correios' published bounds.** They are the smallest and largest CEP that *actually exist* there. The DNE's own start for Areal is `25845000` — precisely the CEP that just failed. `cepIni` is `25845001`. - **The range is sparse.** It is not a list of valid CEPs and iterating it is wrong: Areal's 118 CEPs are scattered across the ~4,900 numbers between the two bounds, about one in forty. That is what `ceps` is for. The field is **omitted, never null**, when the CEP falls in a band no municipality owns, so `faixa == nil` is ordinary and must be handled. It is also `nil` when the response could not be understood: a malformed `faixa` never escalates a clean not-found into a decoding failure. ## 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).