diff options
| -rw-r--r-- | README.md | 41 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -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 | ||||
| -rw-r--r-- | test/cepis.test.ts | 126 |
8 files changed, 333 insertions, 16 deletions
@@ -192,6 +192,47 @@ try { `RateLimited` exposes `retryAfterMs` and `ServerError` exposes `status`. +### When the CEP does not exist + +`CEPNotFound` may carry a `faixa`: the municipality the CEP _would_ belong to. +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. + +```ts +import { CEPNotFound, lookup } from "cepis"; + +try { + await lookup("25845000"); +} catch (error) { + if (error instanceof CEPNotFound && error.faixa) { + error.faixa.localidade; // "Areal" + error.faixa.uf; // "RJ" + error.faixa.ibge; // "3300225" + error.faixa.cep_ini; // "25845001" + error.faixa.cep_fim; // "25849899" + error.faixa.ceps; // 118 + error.faixa.label; // "Areal/RJ" + } +} +``` + +Three things about it: + +- **It is still a 404.** `faixa` is context for the error, never an address. + Showing "did you mean a CEP in Areal/RJ?" is the point; treating it as a + result is a wrong delivery. +- **`cep_ini`/`cep_fim` are the CEPs that actually exist there**, not the range + the Correios publish. The DNE's own start for Areal is `25845000` — exactly + the CEP that just failed. +- **The range is sparse.** Areal's 118 CEPs are scattered across the ~4,900 + numbers between `25845001` and `25849899`. That is what `ceps` is for: the + pair is not a list of valid CEPs, and iterating it yields mostly 404s. + +When the CEP falls in a band no municipality owns, the API **omits** the field +and `error.faixa` is `undefined` — never `null`. A missing or unreadable +`faixa` never turns a clean not-found into a parse failure. + `InvalidResponse` almost always means something between you and the API is 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. diff --git a/package.json b/package.json index 5958e69..cc38c08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cepis", - "version": "0.1.0", + "version": "0.2.0", "description": "TypeScript client for the public CEP API at cep.is — official Correios data (DNE)", "keywords": [ "cep", 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"; diff --git a/test/cepis.test.ts b/test/cepis.test.ts index 911aa1d..67677fe 100644 --- a/test/cepis.test.ts +++ b/test/cepis.test.ts @@ -5,6 +5,7 @@ import { CEPNotFound, CepisError, Client, + Faixa, InvalidCEP, InvalidResponse, NetworkError, @@ -31,6 +32,17 @@ const SE_ADDRESS: Record<string, unknown> = { siafi: "7107", }; +const AREAL_FAIXA: Record<string, unknown> = { + localidade: "Areal", + uf: "RJ", + estado: "Rio de Janeiro", + regiao: "Sudeste", + ibge: "3300225", + cep_ini: "25845001", + cep_fim: "25849899", + ceps: 118, +}; + const CSV_HEADER = "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"; @@ -217,7 +229,119 @@ describe("lookup", () => { } finally { probe.stop(true); } - expect(seen[0]).toContain("cepis-typescript/0.1.0"); + expect(seen[0]).toContain("cepis-typescript/0.2.0"); + }); +}); + +describe("faixa", () => { + async function notFound(cep: string): Promise<CEPNotFound> { + try { + await client.lookup(cep); + } catch (error) { + expect(error).toBeInstanceOf(CEPNotFound); + return error as CEPNotFound; + } + throw new Error("expected CEPNotFound"); + } + + test("names the municipality the CEP would belong to", async () => { + server.routes.respond("/25845000", { + status: 404, + body: JSON.stringify({ + erro: "cep_nao_encontrado", + mensagem: "CEP não existe na base do DNE", + faixa: AREAL_FAIXA, + }), + }); + + const error = await notFound("25845000"); + expect(error.faixa).toBeInstanceOf(Faixa); + expect(error.faixa?.localidade).toBe("Areal"); + expect(error.faixa?.uf).toBe("RJ"); + expect(error.faixa?.estado).toBe("Rio de Janeiro"); + expect(error.faixa?.regiao).toBe("Sudeste"); + expect(error.faixa?.ibge).toBe("3300225"); + expect(error.faixa?.label).toBe("Areal/RJ"); + }); + + test("the range is what exists there, and it is sparse", async () => { + server.routes.respond("/25845000", { + status: 404, + body: JSON.stringify({ erro: "cep_nao_encontrado", faixa: AREAL_FAIXA }), + }); + + const error = await notFound("25845000"); + // The DNE's own start for Areal is 25845000 — the CEP that just failed. + expect(error.faixa?.cep_ini).toBe("25845001"); + expect(error.faixa?.cep_fim).toBe("25849899"); + // 118 CEPs across ~4,900 numbers: the pair is not a list of valid CEPs. + expect(error.faixa?.ceps).toBe(118); + }); + + test("a faixa is still a not-found, never an address", async () => { + server.routes.respond("/25845000", { + status: 404, + body: JSON.stringify({ erro: "cep_nao_encontrado", faixa: AREAL_FAIXA }), + }); + + const promise = client.lookup("25845000"); + await expect(promise).rejects.toBeInstanceOf(CEPNotFound); + await expect(promise).rejects.toThrow("25845000"); + await expect(promise).rejects.toThrow("Areal/RJ"); + expect(server.routes.count("/25845000")).toBe(1); + }); + + test("a band no municipality owns omits the field", async () => { + server.routes.respond("/99999999", { + status: 404, + body: JSON.stringify({ + erro: "cep_nao_encontrado", + mensagem: "CEP não existe na base do DNE", + }), + }); + + const error = await notFound("99999999"); + expect(error.faixa).toBeUndefined(); + expect(error.cep).toBe("99999999"); + expect(error.message).not.toContain("belongs to"); + }); + + test("a faixa that cannot be read costs nothing", async () => { + const useless: unknown[] = [null, "Areal", [], {}, { localidade: "" }, { uf: "RJ" }]; + for (const faixa of useless) { + server.routes.respond("/99999999", { + status: 404, + body: JSON.stringify({ erro: "cep_nao_encontrado", faixa }), + }); + const error = await notFound("99999999"); + expect(error.faixa).toBeUndefined(); + } + }); + + test("a 404 that is not JSON still yields a clean not-found", async () => { + server.routes.respond("/99999999", { + status: 404, + body: "<html>captive portal</html>", + }); + + const error = await notFound("99999999"); + expect(error.faixa).toBeUndefined(); + }); + + test("a partial faixa keeps what it can", async () => { + server.routes.respond("/99999999", { + status: 404, + body: JSON.stringify({ + erro: "cep_nao_encontrado", + faixa: { localidade: "Areal", ceps: "118" }, + }), + }); + + const error = await notFound("99999999"); + expect(error.faixa?.localidade).toBe("Areal"); + expect(error.faixa?.label).toBe("Areal"); + expect(error.faixa?.ceps).toBe(118); + expect(error.faixa?.cep_ini).toBe(""); }); }); |