diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/client.ts | 55 | ||||
| -rw-r--r-- | src/errors.ts | 18 | ||||
| -rw-r--r-- | src/index.ts | 6 | ||||
| -rw-r--r-- | src/models.ts | 99 | ||||
| -rw-r--r-- | src/version.ts | 2 |
5 files changed, 166 insertions, 14 deletions
diff --git a/src/client.ts b/src/client.ts index c7dac15..3e48d7a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,7 +19,7 @@ import { RateLimited, ServerError, } from "./errors.js"; -import { Address, Health, type RawObject } from "./models.js"; +import { Address, Faixa, Health, type RawObject } from "./models.js"; import { VERSION } from "./version.js"; /** Where the public API lives. */ @@ -235,6 +235,19 @@ export class Client { * message: the format already passed local validation, so the server knows * something this client does not. * + * A CEP that does not exist throws {@link CEPNotFound}, which may carry a + * {@link Faixa} naming the municipality the number would belong to: + * + * ```ts + * try { + * await client.lookup("25845000"); + * } catch (error) { + * if (error instanceof CEPNotFound && error.faixa) { + * error.faixa.localidade; // "Areal" — still a 404, still no address + * } + * } + * ``` + * * @throws {InvalidCEP} the CEP is not 8 digits — no request is made. * @throws {CEPNotFound} well-formed, but not in the database. * @throws {RateLimited} 429 after every attempt. @@ -247,7 +260,13 @@ export class Client { const reply = await this.request(`/${number}`); if (reply.status === 404) { - throw new CEPNotFound(`CEP ${number} does not exist in the DNE database`, number); + const faixa = Faixa.fromJson(errorBody(reply.body)?.faixa); + const belongs = faixa === undefined ? "" : ` — that band belongs to ${faixa.label}`; + throw new CEPNotFound( + `CEP ${number} does not exist in the DNE database${belongs}`, + number, + faixa, + ); } if (reply.status === 400) { throw new InvalidCEP(errorMessage(reply.body, `CEP ${number} rejected`)); @@ -417,17 +436,31 @@ export class Client { } } -function errorMessage(body: string, fallback: string): string { +/** + * The body of an error response as an object, or `undefined` when it is not + * one. + * + * Never throws. An error response is already the bad news; a proxy answering + * with HTML in its place must not upgrade that into a second, different + * failure. + */ +function errorBody(body: string): RawObject | undefined { + let data: unknown; try { - const data: unknown = JSON.parse(body); - if (typeof data === "object" && data !== null && !Array.isArray(data)) { - const message = (data as RawObject).mensagem; - if (typeof message === "string" && message !== "") { - return message; - } - } + data = JSON.parse(body); } catch { - return fallback; + return undefined; + } + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return undefined; + } + return data as RawObject; +} + +function errorMessage(body: string, fallback: string): string { + const message = errorBody(body)?.mensagem; + if (typeof message === "string" && message !== "") { + return message; } return fallback; } diff --git a/src/errors.ts b/src/errors.ts index 4e5dbb2..1a1e267 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -20,6 +20,8 @@ * @module */ +import type { Faixa } from "./models.js"; + /** * Base of every error this library throws. Catch it to handle any failure. * @@ -52,15 +54,29 @@ export class InvalidCEP extends CepisError { * * Never thrown by {@link Client.lookupMany}, which reports a missing CEP as * `null` at its position instead. + * + * `faixa` says which municipality the CEP *would* belong to, when the number + * falls inside a band some municipality owns. It is context for the failure and + * nothing more — this is still a 404, and the CEP still does not exist. */ export class CEPNotFound extends CepisError { /** The 8-digit CEP that was looked up. */ readonly cep: string; + /** + * The municipality the CEP would have belonged to, or `undefined` when it + * falls in a band no municipality owns. + * + * Read {@link Faixa} before using it: `cep_ini`/`cep_fim` are the CEPs that + * exist there, the range between them is sparse, and none of it makes the + * looked-up CEP valid. + */ + readonly faixa?: Faixa; - constructor(message: string, cep: string, options?: ErrorOptions) { + constructor(message: string, cep: string, faixa?: Faixa, options?: ErrorOptions) { super(message, options); this.name = "CEPNotFound"; this.cep = cep; + this.faixa = faixa; } } diff --git a/src/index.ts b/src/index.ts index 0ac9631..6acccb3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,9 @@ * const address = await lookup(cepFromForm); * } catch (error) { * if (error instanceof CEPNotFound) { - * // the CEP does not exist: ask the user for another + * // the CEP does not exist: ask the user for another. `error.faixa`, when + * // present, names the municipality it would have belonged to — context + * // for the message, not an address. * } else if (error instanceof CepisError) { * // network, server, rate limit: retry later * } else { @@ -70,9 +72,11 @@ export { } from "./errors.js"; export { Address, + Faixa, Geo, Health, type AddressFields, + type FaixaFields, type GeoFields, type HealthFields, type RawObject, diff --git a/src/models.ts b/src/models.ts index 7e20b54..7125336 100644 --- a/src/models.ts +++ b/src/models.ts @@ -246,6 +246,105 @@ export class Address { } } +/** Every field of a {@link Faixa}, as plain data. */ +export interface FaixaFields { + localidade: string; + uf: string; + estado: string; + regiao: string; + ibge: string; + cep_ini: string; + cep_fim: string; + ceps: number; +} + +/** + * The municipality a CEP that does not exist *would* belong to. + * + * It comes attached to {@link CEPNotFound}, on a response that is still a + * **404**. It is context for an error, never an address: nothing here says the + * CEP exists, and code that treats a `faixa` as a result turns a clean + * not-found into a wrong delivery. + * + * It exists because a failing CEP is rarely random — it is someone who knows + * the city and guessed the number, and the classic guess is the `-000` that 957 + * municipalities never received. + * + * `cep_ini` and `cep_fim` are the smallest and the largest CEP that **actually + * exist** in that municipality, not the range the Correios publish for it. For + * Areal/RJ the DNE's own start is `25845000` — precisely the CEP that just + * failed — while `cep_ini` is `25845001`. + * + * The range is **sparse**. Areal's 118 CEPs are scattered across the ~4,900 + * numbers between `25845001` and `25849899`, so the pair is not a list of valid + * CEPs and iterating it produces mostly 404s. `ceps` is there to make that + * density visible: 118 out of 4,900. + */ +export class Faixa implements FaixaFields { + /** Municipality that owns the band the CEP falls in. */ + readonly localidade: string; + /** Two-letter state code, such as `"RJ"`. */ + readonly uf: string; + /** State name, spelled out. */ + readonly estado: string; + /** Region of the country, such as `"Sudeste"`. */ + readonly regiao: string; + /** IBGE municipality code. */ + readonly ibge: string; + /** Smallest CEP that exists there — not the band's published start. */ + readonly cep_ini: string; + /** Largest CEP that exists there — not the band's published end. */ + readonly cep_fim: string; + /** How many CEPs actually exist between `cep_ini` and `cep_fim`. */ + readonly ceps: number; + + constructor(fields: FaixaFields) { + this.localidade = fields.localidade; + this.uf = fields.uf; + this.estado = fields.estado; + this.regiao = fields.regiao; + this.ibge = fields.ibge; + this.cep_ini = fields.cep_ini; + this.cep_fim = fields.cep_fim; + this.ceps = fields.ceps; + } + + /** The municipality as `"Areal/RJ"`, for a message shown to a person. */ + get label(): string { + return this.uf === "" ? this.localidade : `${this.localidade}/${this.uf}`; + } + + /** + * Build from the `faixa` block of a 404 body, or `undefined` when there is + * none to read. + * + * `undefined`, not `null`, because the API **omits** the field rather than + * sending null when the CEP falls in a band no municipality owns. Absent and + * unreadable are the same answer here: a malformed block must never cost the + * caller the not-found error it was already getting. + */ + static fromJson(data: unknown): Faixa | undefined { + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return undefined; + } + const block = data as RawObject; + const localidade = toText(block.localidade).trim(); + if (localidade === "") { + return undefined; + } + return new Faixa({ + localidade, + uf: toText(block.uf), + estado: toText(block.estado), + regiao: toText(block.regiao), + ibge: toText(block.ibge), + cep_ini: toText(block.cep_ini), + cep_fim: toText(block.cep_fim), + ceps: toNumber(block.ceps) ?? 0, + }); + } +} + /** Every field of a {@link Health} report, as plain data. */ export interface HealthFields { status: string; diff --git a/src/version.ts b/src/version.ts index b024a5d..b370336 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ /** The library's version, as it appears in the default `User-Agent`. */ -export const VERSION = "0.1.0"; +export const VERSION = "0.2.0"; |