diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:33:24 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:33:24 -0300 |
| commit | d7ca2d6f4b1c5c1f5f1b6ce6ffad35aaffee5b0f (patch) | |
| tree | 21aca875c28f4d7d9ec3b0a807e7c7f15a6766d8 | |
| parent | 72a4b451c9d06f7a97cac2f43f1b7f52fafcd3b7 (diff) | |
| download | swift-0.2.0.tar.gz | |
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.
| -rw-r--r-- | README.md | 50 | ||||
| -rw-r--r-- | Sources/Cepis/Client.swift | 41 | ||||
| -rw-r--r-- | Sources/Cepis/Errors.swift | 44 | ||||
| -rw-r--r-- | Sources/Cepis/Models.swift | 125 | ||||
| -rw-r--r-- | Tests/CepisTests/CepisTests.swift | 165 |
5 files changed, 409 insertions, 16 deletions
@@ -20,7 +20,7 @@ Add the package to `Package.swift`: ```swift dependencies: [ - .package(url: "https://git.cep.is/swift.git", from: "0.1.0") + .package(url: "https://git.cep.is/swift.git", from: "0.2.0") ] ``` @@ -169,7 +169,7 @@ 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 | +| `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 | @@ -190,6 +190,52 @@ do { 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 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 diff --git a/Tests/CepisTests/CepisTests.swift b/Tests/CepisTests/CepisTests.swift index 46699bc..deabead 100644 --- a/Tests/CepisTests/CepisTests.swift +++ b/Tests/CepisTests/CepisTests.swift @@ -14,6 +14,14 @@ let seAddressJSON = """ "ddd":"11","siafi":"7107"} """ +/// The 404 for `25845000`: the CEP the DNE itself lists as Areal's first, and +/// which nonetheless does not exist. +let arealNotFoundJSON = """ + {"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE", + "faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro","regiao":"Sudeste", + "ibge":"3300225","cep_ini":"25845001","cep_fim":"25849899","ceps":118}} + """ + let csvHeader = "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi" @@ -65,7 +73,7 @@ final class NormalisationTests: XCTestCase { func testInvalidCEPIsNotRetriable() { XCTAssertFalse(CepisError.invalidCEP("x").isRetriable) - XCTAssertFalse(CepisError.cepNotFound("x").isRetriable) + XCTAssertFalse(CepisError.cepNotFound(message: "x", faixa: nil).isRetriable) XCTAssertTrue(CepisError.networkError(message: "x").isRetriable) } } @@ -142,8 +150,9 @@ final class LookupTests: ServerBackedTestCase { do { _ = try await client.lookup("99999999") XCTFail("expected cepNotFound") - } catch CepisError.cepNotFound(let message) { + } catch CepisError.cepNotFound(let message, let faixa) { XCTAssertTrue(message.contains("99999999")) + XCTAssertNil(faixa) } } @@ -188,6 +197,158 @@ final class LookupTests: ServerBackedTestCase { } } +final class NotFoundFaixaTests: ServerBackedTestCase { + /// Serves `body` as the 404 for `cep` and returns the faixa that survived. + private func faixaFromNotFound(_ body: String, cep: String = "25845000") async throws -> Faixa? + { + routes.respond("/\(cep)", status: 404, body: body) + do { + _ = try await client.lookup(cep) + XCTFail("expected cepNotFound") + return nil + } catch CepisError.cepNotFound(_, let faixa) { + return faixa + } + } + + func testNamesTheMunicipalityTheCEPWouldBelongTo() async throws { + let decoded = try await faixaFromNotFound(arealNotFoundJSON) + let faixa = try XCTUnwrap(decoded) + XCTAssertEqual(faixa.localidade, "Areal") + XCTAssertEqual(faixa.uf, "RJ") + XCTAssertEqual(faixa.estado, "Rio de Janeiro") + XCTAssertEqual(faixa.regiao, "Sudeste") + XCTAssertEqual(faixa.ibge, "3300225") + XCTAssertEqual(faixa.localidadeUF, "Areal/RJ") + } + + /// The faixa decorates the 404; it must never promote it to a success. + func testStillThrowsAndReturnsNoAddress() async throws { + routes.respond("/25845000", status: 404, body: arealNotFoundJSON) + do { + let address = try await client.lookup("25845000") + XCTFail("a faixa must not turn a not-found into \(address)") + } catch CepisError.cepNotFound(let message, let faixa) { + XCTAssertTrue(message.contains("25845000")) + XCTAssertTrue(message.contains("does not exist")) + XCTAssertNotNil(faixa) + } + } + + /// `cep_ini` is the first CEP that *exists*, not the DNE's published start + /// — which for Areal is `25845000`, the very CEP that just failed. + func testBoundsAreTheCEPsThatExistNotTheDNEStart() async throws { + let decoded = try await faixaFromNotFound(arealNotFoundJSON) + let faixa = try XCTUnwrap(decoded) + XCTAssertEqual(faixa.cepIni, "25845001") + XCTAssertEqual(faixa.cepFim, "25849899") + XCTAssertNotEqual(faixa.cepIni, "25845000") + } + + /// The pair is not a list: 118 CEPs across ~4,900 numbers. + func testRangeIsSparse() async throws { + let decoded = try await faixaFromNotFound(arealNotFoundJSON) + let faixa = try XCTUnwrap(decoded) + XCTAssertEqual(faixa.ceps, 118) + + let first = try XCTUnwrap(Int(faixa.cepIni)) + let last = try XCTUnwrap(Int(faixa.cepFim)) + let span = last - first + 1 + XCTAssertEqual(span, 4899) + XCTAssertLessThan(faixa.ceps, span / 10) + } + + func testAbsentWhenNoMunicipalityOwnsTheBand() async throws { + let body = #"{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}"# + let decoded = try await faixaFromNotFound(body) + XCTAssertNil(decoded) + } + + func testNullFaixaIsNil() async throws { + let decoded = try await faixaFromNotFound(#"{"erro":"cep_nao_encontrado","faixa":null}"#) + XCTAssertNil(decoded) + } + + func testEmptyFaixaIsNil() async throws { + let decoded = try await faixaFromNotFound(#"{"erro":"cep_nao_encontrado","faixa":{}}"#) + XCTAssertNil(decoded) + } + + func testFaixaWithoutAMunicipalityIsNil() async throws { + for value in [#""""#, #"" ""#, "123", "null", "[]"] { + let body = #"{"erro":"cep_nao_encontrado","faixa":{"localidade":\#(value),"uf":"RJ"}}"# + let decoded = try await faixaFromNotFound(body) + XCTAssertNil(decoded, "localidade \(value)") + } + } + + func testMalformedFaixaDoesNotBecomeADecodingFailure() async throws { + let bodies = [ + #"{"erro":"cep_nao_encontrado","faixa":"Areal"}"#, + #"{"erro":"cep_nao_encontrado","faixa":[{"localidade":"Areal"}]}"#, + #"{"erro":"cep_nao_encontrado","faixa":{"localidade":{"nome":"Areal"}}}"#, + ] + for body in bodies { + let decoded = try await faixaFromNotFound(body) + XCTAssertNil(decoded, body) + } + } + + /// A 404 body that is not JSON at all is still a clean not-found, never an + /// `invalidResponse`. + func testNonJSONBodyStillYieldsNotFound() async throws { + for body in ["<html>captive portal</html>", ""] { + let decoded = try await faixaFromNotFound(body) + XCTAssertNil(decoded, body) + } + } + + func testToleratesOddFieldTypesAroundTheMunicipality() async throws { + let body = """ + {"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal","uf":"RJ", + "ibge":3300225,"cep_ini":"25845001","ceps":118.0}} + """ + let decoded = try await faixaFromNotFound(body) + let faixa = try XCTUnwrap(decoded) + XCTAssertEqual(faixa.localidade, "Areal") + XCTAssertEqual(faixa.ceps, 118) + XCTAssertEqual(faixa.ibge, "") + XCTAssertEqual(faixa.cepFim, "") + } + + func testDescriptionMentionsTheMunicipality() async throws { + routes.respond("/25845000", status: 404, body: arealNotFoundJSON) + do { + _ = try await client.lookup("25845000") + XCTFail("expected cepNotFound") + } catch let error as CepisError { + XCTAssertTrue(error.description.contains("Areal/RJ"), error.description) + XCTAssertEqual(error.errorDescription, error.description) + XCTAssertEqual(error.faixa?.localidade, "Areal") + // `message` stays what it always was, for callers matching on it. + XCTAssertFalse(error.message.contains("Areal")) + } + } + + func testDescriptionWithoutAFaixaIsJustTheMessage() { + let error = CepisError.cepNotFound(message: "CEP 99999999 does not exist", faixa: nil) + XCTAssertEqual(error.description, error.message) + XCTAssertEqual(error.errorDescription, error.message) + XCTAssertNil(error.faixa) + } + + func testFaixaAccessorIsNilForTheOtherCases() { + XCTAssertNil(CepisError.invalidCEP("x").faixa) + XCTAssertNil(CepisError.networkError(message: "x").faixa) + XCTAssertNil(CepisError.serverError(message: "x", status: 500).faixa) + } + + func testAFaixaIsStillNotRetriable() { + let faixa = Faixa(localidade: "Areal", uf: "RJ") + XCTAssertFalse(CepisError.cepNotFound(message: "x", faixa: faixa).isRetriable) + } +} + final class RetryTests: ServerBackedTestCase { func testRetriesOn500ThenSucceeds() async throws { routes.respond( |