From d7ca2d6f4b1c5c1f5f1b6ce6ffad35aaffee5b0f Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Mon, 27 Jul 2026 20:33:24 -0300 Subject: Carry the municipality on a not-found CEP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/Cepis/Client.swift | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) (limited to 'Sources/Cepis/Client.swift') 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? -- cgit v1.3