summaryrefslogtreecommitdiffstats
path: root/Sources
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:33:24 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:33:24 -0300
commitd7ca2d6f4b1c5c1f5f1b6ce6ffad35aaffee5b0f (patch)
tree21aca875c28f4d7d9ec3b0a807e7c7f15a6766d8 /Sources
parent72a4b451c9d06f7a97cac2f43f1b7f52fafcd3b7 (diff)
downloadswift-d7ca2d6f4b1c5c1f5f1b6ce6ffad35aaffee5b0f.tar.gz
Carry the municipality on a not-found CEPHEADv0.2.0main
A CEP that fails a lookup is rarely a random number: it is almost always someone who knows the city and guessed the digits. The classic guess is the -000, which 957 municipalities never received. Answering that with a bare "not found" throws away what the API knows — that the number is in Areal/RJ's band — and leaves the caller unable to tell a typo apart from a city that has no such CEP. The 404 now carries an optional faixa, so cepNotFound carries it too. It decorates the error and never replaces it: the CEP still does not exist and the call still throws. Parsing is deliberately incapable of escalating — a faixa that is absent, null, empty or malformed becomes nil, because losing the decoration must never cost the clean not-found underneath it. Faixa offers no contains(): cep_ini and cep_fim are the smallest and largest CEP that actually exist, not the bounds the Correios publish, and the range between them is sparse — Areal's 118 CEPs are spread over some 4,900 numbers. Only a lookup can answer whether a CEP exists, and an API that appeared to answer it from the pair would be lying. Widening cepNotFound's payload breaks anyone matching the case with a single binding, hence 0.2.0.
Diffstat (limited to 'Sources')
-rw-r--r--Sources/Cepis/Client.swift41
-rw-r--r--Sources/Cepis/Errors.swift44
-rw-r--r--Sources/Cepis/Models.swift125
3 files changed, 198 insertions, 12 deletions
diff --git a/Sources/Cepis/Client.swift b/Sources/Cepis/Client.swift
index e0d5135..f24effb 100644
--- a/Sources/Cepis/Client.swift
+++ b/Sources/Cepis/Client.swift
@@ -74,7 +74,7 @@ public struct Client: @unchecked Sendable {
public static let maxBatch = 500
/// This library's version, as it appears in the default `User-Agent`.
- public static let version = "0.1.0"
+ public static let version = "0.2.0"
/// A shared client with the default configuration.
///
@@ -176,9 +176,16 @@ public struct Client: @unchecked Sendable {
/// the API's own message: the format already passed local validation, so
/// the server knows something this client does not.
///
+ /// A 404 may carry a ``Faixa``: the municipality the CEP *would* have
+ /// belonged to, on
+ /// ``CepisError/cepNotFound(message:faixa:)``. It is context for the error
+ /// and nothing more — the CEP still does not exist, and the call still
+ /// throws.
+ ///
/// - 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;
+ /// before any request is made;
+ /// ``CepisError/cepNotFound(message:faixa:)`` 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.
@@ -187,7 +194,9 @@ public struct Client: @unchecked Sendable {
let response = try await perform(path: "/\(number)")
if response.status == 404 {
- throw CepisError.cepNotFound("CEP \(number) does not exist in the DNE database")
+ throw CepisError.cepNotFound(
+ message: "CEP \(number) does not exist in the DNE database",
+ faixa: Client.faixa(response.body))
}
if response.status == 400 {
throw CepisError.invalidCEP(
@@ -420,6 +429,30 @@ public struct Client: @unchecked Sendable {
}
}
+ /// Reads the optional `faixa` out of a 404 body, or gives up quietly.
+ ///
+ /// Every way this can go wrong ends in `nil`: a body that is not JSON, a
+ /// `faixa` that is absent, null, empty or malformed. That is deliberate.
+ /// The caller is already throwing ``CepisError/cepNotFound(message:faixa:)``
+ /// and the faixa is only a decoration on it; letting a decode failure here
+ /// escalate would swap a clean not-found for an
+ /// ``CepisError/invalidResponse(_:)`` and lose the real answer.
+ private static func faixa(_ body: Data) -> Faixa? {
+ struct NotFound: Decodable {
+ let faixa: Faixa?
+
+ enum CodingKeys: String, CodingKey {
+ case faixa
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.faixa = (try? container.decodeIfPresent(Faixa.self, forKey: .faixa)) ?? nil
+ }
+ }
+ return (try? JSONDecoder().decode(NotFound.self, from: body))?.faixa
+ }
+
private static func apiMessage(_ body: Data, fallback: String) -> String {
struct APIError: Decodable {
let mensagem: String?
diff --git a/Sources/Cepis/Errors.swift b/Sources/Cepis/Errors.swift
index 1472f81..089121d 100644
--- a/Sources/Cepis/Errors.swift
+++ b/Sources/Cepis/Errors.swift
@@ -6,15 +6,15 @@ import Foundation
/// 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.
+/// - ``invalidCEP(_:)`` and ``cepNotFound(message:faixa:)`` 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.
+/// That split is why ``cepNotFound(message:faixa:)`` 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.
///
@@ -25,7 +25,13 @@ public enum CepisError: Error, Sendable {
case invalidCEP(String)
/// The CEP is well-formed but does not exist in the DNE database.
- case cepNotFound(String)
+ ///
+ /// `faixa` names the municipality the CEP *would* have belonged to, when
+ /// the API can say — see ``Faixa``. It is `nil` whenever the API omits it,
+ /// which happens for a CEP in a band no municipality owns, and whenever
+ /// what came back could not be understood. It never changes the meaning of
+ /// the case: the CEP does not exist either way.
+ case cepNotFound(message: String, faixa: Faixa?)
/// The API (or the edge in front of it) answered 429 on every attempt.
///
@@ -53,7 +59,7 @@ public enum CepisError: Error, Sendable {
public var message: String {
switch self {
case .invalidCEP(let message): return message
- case .cepNotFound(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
@@ -61,6 +67,15 @@ public enum CepisError: Error, Sendable {
}
}
+ /// The municipality the missing CEP would have belonged to, when known.
+ ///
+ /// `nil` for every case other than ``cepNotFound(message:faixa:)``, so a
+ /// caller can reach for it without pattern-matching the enum first.
+ public var faixa: Faixa? {
+ guard case .cepNotFound(_, let faixa) = self else { return nil }
+ return faixa
+ }
+
/// Whether making the same call again later stands a chance of succeeding.
///
/// `true` for rate limiting, server errors and network failures; `false`
@@ -74,9 +89,22 @@ public enum CepisError: Error, Sendable {
}
extension CepisError: CustomStringConvertible {
- public var description: String { message }
+ /// ``message``, plus the municipality when a ``Faixa`` came with the
+ /// not-found.
+ ///
+ /// The addition is the whole point of the faixa: *"CEP 25845000 does not
+ /// exist in the DNE database (that range is Areal/RJ)"* tells the person at
+ /// the keyboard they typed the right city and the wrong number, which
+ /// *"does not exist"* on its own does not.
+ ///
+ /// ``message`` stays exactly what it was, for callers that log or match on
+ /// it.
+ public var description: String {
+ guard let faixa else { return message }
+ return "\(message) (that range is \(faixa.localidadeUF))"
+ }
}
extension CepisError: LocalizedError {
- public var errorDescription: String? { message }
+ public var errorDescription: String? { description }
}
diff --git a/Sources/Cepis/Models.swift b/Sources/Cepis/Models.swift
index 8d8df35..f4c2291 100644
--- a/Sources/Cepis/Models.swift
+++ b/Sources/Cepis/Models.swift
@@ -197,6 +197,131 @@ public struct Address: Codable, Sendable, Equatable, Hashable {
}
}
+/// The municipality a CEP that does not exist *would* belong to.
+///
+/// It arrives on the **404** of ``Client/lookup(_:)``, carried by
+/// ``CepisError/cepNotFound(message:faixa:)``. It is context for an error, not
+/// an address: the CEP asked for still does not exist, and a `Faixa` never
+/// turns a not-found into a success. Use it to tell someone *"25845-000 is not
+/// a CEP, but you are looking at Areal/RJ"* instead of *"not found"*.
+///
+/// A failing CEP is rarely a random number: it is usually someone who knows the
+/// city and guessed the digits. The classic guess is the `-000`, which 957
+/// municipalities never received.
+///
+/// ## The bounds are not the Correios' bounds
+///
+/// ``cepIni`` and ``cepFim`` are the smallest and the largest CEP that
+/// **actually exist** in that municipality — not the range the Correios
+/// publish. For Areal the DNE's own start is `25845000`, which is precisely the
+/// CEP that just failed; ``cepIni`` is `25845001`.
+///
+/// ## The range is sparse
+///
+/// The pair is **not** a list of valid CEPs and iterating it is wrong. Areal's
+/// ``ceps`` are 118 numbers scattered across the ~4,900 between `25845001` and
+/// `25849899` — roughly one in forty. That is what ``ceps`` is for: it is the
+/// count that stops the pair from being read as a contiguous block. For the
+/// same reason this type deliberately offers no `contains` — only a lookup can
+/// answer whether a CEP exists.
+///
+/// The field is **omitted, never null**, when the CEP falls in a band no
+/// municipality owns, so absence is ordinary and must be handled.
+///
+/// The property names are the API's own, in Portuguese; only the casing is
+/// adapted, and ``cepIni``/``cepFim`` map to the JSON's `cep_ini`/`cep_fim`.
+public struct Faixa: Codable, Sendable, Equatable, Hashable {
+ /// The municipality's name: `"Areal"`.
+ public let localidade: String
+ /// The two-letter state code: `"RJ"`.
+ public let uf: String
+ /// The state's full name: `"Rio de Janeiro"`.
+ public let estado: String
+ /// The region: `"Sudeste"`.
+ public let regiao: String
+ /// The IBGE municipality code, the key to join against census data.
+ public let ibge: String
+ /// The **smallest CEP that exists** there, unmasked. JSON `cep_ini`.
+ public let cepIni: String
+ /// The **largest CEP that exists** there, unmasked. JSON `cep_fim`.
+ public let cepFim: String
+ /// How many CEPs actually exist between ``cepIni`` and ``cepFim``.
+ ///
+ /// Almost always far fewer than the arithmetic distance between the two:
+ /// the range is sparse, and this is the number that says by how much.
+ public let ceps: Int
+
+ enum CodingKeys: String, CodingKey {
+ case localidade, uf, estado, regiao, ibge, ceps
+ case cepIni = "cep_ini"
+ case cepFim = "cep_fim"
+ }
+
+ public init(
+ localidade: String,
+ uf: String = "",
+ estado: String = "",
+ regiao: String = "",
+ ibge: String = "",
+ cepIni: String = "",
+ cepFim: String = "",
+ ceps: Int = 0
+ ) {
+ self.localidade = localidade
+ self.uf = uf
+ self.estado = estado
+ self.regiao = regiao
+ self.ibge = ibge
+ self.cepIni = cepIni
+ self.cepFim = cepFim
+ self.ceps = ceps
+ }
+
+ /// Decodes the API's `faixa` object.
+ ///
+ /// Every field is tolerated except ``localidade``: a faixa that does not
+ /// name a municipality says nothing, so an absent or empty one throws and
+ /// the caller turns that into `nil`. Nothing here can escalate — the error
+ /// being decorated is a not-found either way, and losing the decoration
+ /// must never cost the error itself.
+ 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)) ?? "")
+ .trimmingCharacters(in: .whitespaces)
+ }
+
+ let localidade = string(.localidade)
+ guard !localidade.isEmpty else {
+ throw DecodingError.dataCorruptedError(
+ forKey: .localidade, in: container,
+ debugDescription: "a faixa that names no municipality carries no information")
+ }
+
+ self.localidade = localidade
+ self.uf = string(.uf)
+ self.estado = string(.estado)
+ self.regiao = string(.regiao)
+ self.ibge = string(.ibge)
+ self.cepIni = string(.cepIni)
+ self.cepFim = string(.cepFim)
+ if let exact = try? container.decode(Int.self, forKey: .ceps) {
+ self.ceps = exact
+ } else if let rounded = try? container.decode(Double.self, forKey: .ceps) {
+ self.ceps = Int(rounded)
+ } else {
+ self.ceps = 0
+ }
+ }
+
+ /// The municipality as `"Areal/RJ"`, or just its name when there is no
+ /// state code.
+ public var localidadeUF: String {
+ uf.isEmpty ? localidade : "\(localidade)/\(uf)"
+ }
+}
+
/// The response of `GET /health`.
///
/// Good for two things: checking that the API answers, and knowing **which