diff options
Diffstat (limited to 'Sources/Cepis/Models.swift')
| -rw-r--r-- | Sources/Cepis/Models.swift | 125 |
1 files changed, 125 insertions, 0 deletions
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 |