diff options
Diffstat (limited to 'Sources/Cepis')
| -rw-r--r-- | Sources/Cepis/Client.swift | 41 | ||||
| -rw-r--r-- | Sources/Cepis/Errors.swift | 44 | ||||
| -rw-r--r-- | Sources/Cepis/Models.swift | 125 |
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 |