summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/client.ts55
-rw-r--r--src/errors.ts18
-rw-r--r--src/index.ts6
-rw-r--r--src/models.ts99
-rw-r--r--src/version.ts2
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";