summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/cli.ts140
-rw-r--r--src/client.ts433
-rw-r--r--src/csv.ts92
-rw-r--r--src/errors.ts125
-rw-r--r--src/index.ts110
-rw-r--r--src/models.ts300
-rw-r--r--src/version.ts2
7 files changed, 1202 insertions, 0 deletions
diff --git a/src/cli.ts b/src/cli.ts
new file mode 100644
index 0000000..388e8f4
--- /dev/null
+++ b/src/cli.ts
@@ -0,0 +1,140 @@
+#!/usr/bin/env node
+/**
+ * Minimal CLI: `cepis 01001000`.
+ *
+ * It exists for one concrete case — quickly confirming a local instance
+ * answers before pointing code at it:
+ *
+ * ```sh
+ * cepis --base-url http://localhost:8080 01001000
+ * ```
+ *
+ * A single CEP goes through the unit lookup, which carries `geo`; several go
+ * through the batch, which is one request instead of N and has no `geo`.
+ *
+ * It is not the library's main interface; for that, import the package.
+ *
+ * @module
+ */
+
+import { Client, DEFAULT_URL } from "./client.js";
+import { CepisError } from "./errors.js";
+import type { Address } from "./models.js";
+import { VERSION } from "./version.js";
+
+const USAGE = `cepis ${VERSION} — look CEPs up against the cep.is API.
+
+usage: cepis [--base-url URL] [--timeout MS] <cep> [cep...]
+
+ --base-url URL API URL (default: ${DEFAULT_URL})
+ --timeout MS milliseconds allowed per attempt (default: 10000)
+ -h, --help show this message
+`;
+
+interface Options {
+ baseUrl: string;
+ timeoutMs: number;
+ ceps: string[];
+}
+
+function parseArgs(argv: readonly string[]): Options | null {
+ const options: Options = {
+ baseUrl: DEFAULT_URL,
+ timeoutMs: 10_000,
+ ceps: [],
+ };
+
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i] as string;
+ if (arg === "-h" || arg === "--help") {
+ return null;
+ } else if (arg === "--base-url") {
+ i += 1;
+ const value = argv[i];
+ if (value === undefined) {
+ throw new Error("--base-url needs a value");
+ }
+ options.baseUrl = value;
+ } else if (arg === "--timeout") {
+ i += 1;
+ const value = Number(argv[i]);
+ if (!Number.isFinite(value) || value <= 0) {
+ throw new Error("--timeout needs a positive number of milliseconds");
+ }
+ options.timeoutMs = value;
+ } else if (arg.startsWith("-")) {
+ throw new Error(`unknown option: ${arg}`);
+ } else {
+ options.ceps.push(arg);
+ }
+ }
+
+ if (options.ceps.length === 0) {
+ return null;
+ }
+ return options;
+}
+
+function serialise(address: Address | null): unknown {
+ if (address === null) {
+ return null;
+ }
+ return {
+ cep: address.cep,
+ formattedCep: address.formattedCep,
+ logradouro: address.logradouro,
+ complemento: address.complemento,
+ unidade: address.unidade,
+ bairro: address.bairro,
+ localidade: address.localidade,
+ uf: address.uf,
+ estado: address.estado,
+ regiao: address.regiao,
+ ibge: address.ibge,
+ gia: address.gia,
+ ddd: address.ddd,
+ siafi: address.siafi,
+ geo: address.geo,
+ };
+}
+
+/** Run the CLI. Resolves to the process exit code. */
+export async function main(argv: readonly string[]): Promise<number> {
+ let options: Options | null;
+ try {
+ options = parseArgs(argv);
+ } catch (error) {
+ process.stderr.write(`error: ${(error as Error).message}\n\n${USAGE}`);
+ return 2;
+ }
+
+ if (options === null) {
+ process.stdout.write(USAGE);
+ return 0;
+ }
+
+ const client = new Client({
+ baseUrl: options.baseUrl,
+ timeoutMs: options.timeoutMs,
+ });
+
+ let addresses: (Address | null)[];
+ try {
+ addresses =
+ options.ceps.length === 1
+ ? [await client.lookup(options.ceps[0] as string)]
+ : await client.lookupMany(options.ceps);
+ } catch (error) {
+ if (error instanceof CepisError) {
+ process.stderr.write(`error: ${error.message}\n`);
+ return 1;
+ }
+ throw error;
+ }
+
+ const output = addresses.map(serialise);
+ process.stdout.write(`${JSON.stringify(output.length > 1 ? output : output[0], null, 2)}\n`);
+ return 0;
+}
+
+process.exitCode = await main(process.argv.slice(2));
diff --git a/src/client.ts b/src/client.ts
new file mode 100644
index 0000000..c7dac15
--- /dev/null
+++ b/src/client.ts
@@ -0,0 +1,433 @@
+/**
+ * The HTTP client, built on the platform's `fetch`.
+ *
+ * Retry policy: 429 and the transient 5xx in `RETRIABLE_STATUS`, plus network
+ * failures, are retried with exponentially backed-off, jittered waits. 400 and
+ * 404 are deliberately absent from that set — both are definitive answers
+ * about the request, so retrying returns the same thing more slowly while
+ * burning someone else's quota.
+ *
+ * @module
+ */
+
+import { parseCsv } from "./csv.js";
+import {
+ CEPNotFound,
+ InvalidCEP,
+ InvalidResponse,
+ NetworkError,
+ RateLimited,
+ ServerError,
+} from "./errors.js";
+import { Address, Health, type RawObject } from "./models.js";
+import { VERSION } from "./version.js";
+
+/** Where the public API lives. */
+export const DEFAULT_URL = "https://cep.is";
+
+/** How many CEPs the `/csv` endpoint accepts in one request. */
+export const MAX_BATCH = 500;
+
+const RETRIABLE_STATUS = new Set([429, 500, 502, 503, 504]);
+
+const DIGITS_ONLY = /^\d{8}$/;
+
+const DELAY_SECONDS = /^\d+$/;
+
+const ACCEPT = "application/json, text/csv;q=0.9, */*;q=0.1";
+
+/**
+ * Return the CEP's 8 digits, accepting the mask.
+ *
+ * `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`.
+ *
+ * Throws {@link InvalidCEP} unless exactly 8 digits remain. This runs
+ * **before** any request: a 7-digit CEP is the caller's bug, and spending a
+ * round trip for the server to say so wastes both sides' time.
+ */
+export function normalizeCep(value: string): string {
+ if (typeof value !== "string") {
+ throw new InvalidCEP(`CEP must be a string, got ${typeof value}`);
+ }
+ const cleaned = value.trim().replaceAll("-", "").replaceAll(".", "").replaceAll(" ", "");
+ if (!DIGITS_ONLY.test(cleaned)) {
+ throw new InvalidCEP(`CEP must be 8 digits, got ${JSON.stringify(value)}`);
+ }
+ return cleaned;
+}
+
+function retryAfterMs(value: string | null): number | undefined {
+ if (value === null) {
+ return undefined;
+ }
+ const header = value.trim();
+ if (header === "") {
+ return undefined;
+ }
+ if (DELAY_SECONDS.test(header)) {
+ return Number(header) * 1000;
+ }
+ const target = Date.parse(header);
+ if (Number.isNaN(target)) {
+ return undefined;
+ }
+ return Math.max(0, target - Date.now());
+}
+
+function sleep(ms: number): Promise<void> {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}
+
+function describe(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message;
+ }
+ return String(error);
+}
+
+/**
+ * Everything that can be configured on a {@link Client}. Every field is
+ * optional; the defaults are the ones documented on each property.
+ */
+export interface ClientOptions {
+ /**
+ * Where the API lives. Default `https://cep.is`.
+ *
+ * Point it at a local instance (the API repo brings one up on
+ * `http://localhost:8080` with `just up`) or at a test server. It is the
+ * only option that usually changes between environments. A trailing slash is
+ * stripped, so it cannot produce doubled separators that some proxies
+ * normalise and others 404.
+ */
+ baseUrl?: string;
+
+ /**
+ * Milliseconds allowed for a **single attempt**, request and body read
+ * included. Default `10_000`.
+ *
+ * `fetch` has no per-socket timeout, so this is one deadline over the whole
+ * exchange rather than one per connect and read. The cap on the whole call,
+ * retries and waits included, is {@link ClientOptions.totalTimeoutMs}.
+ */
+ timeoutMs?: number;
+
+ /**
+ * How many attempts in total, counting the first. Default `3`; `1` disables
+ * retrying.
+ *
+ * Three covers the common case (one transient failure) without turning a
+ * real outage into a long wait.
+ */
+ maxAttempts?: number;
+
+ /**
+ * Base of the exponential wait, in milliseconds. Default `500`.
+ *
+ * The actual wait is drawn from `[0, base * 2 ** (n - 1)]` — *full jitter*.
+ * The randomness is not decoration: without it, N clients that fail together
+ * come back together and reproduce the spike that took the service down.
+ */
+ backoffBaseMs?: number;
+
+ /**
+ * Cap on each individual wait, in milliseconds. Default `8_000`.
+ *
+ * It applies to a server-sent `Retry-After` too, or a `Retry-After: 3600`
+ * from a misconfigured proxy would park the caller for an hour.
+ */
+ backoffMaxMs?: number;
+
+ /**
+ * Cap on the whole call, attempts and waits included, in milliseconds.
+ * Undefined by default, which leaves `maxAttempts` as the only bound.
+ *
+ * Set it in code serving a web request: without it the worst case is
+ * `maxAttempts * timeoutMs` plus the sum of the backoffs.
+ */
+ totalTimeoutMs?: number;
+
+ /**
+ * Overrides the `User-Agent`. The default identifies the library and its
+ * version, which is what lets the server diagnose an old client
+ * misbehaving.
+ */
+ userAgent?: string;
+}
+
+interface Reply {
+ status: number;
+ body: string;
+ headers: Headers;
+}
+
+/**
+ * Client for the cep.is API.
+ *
+ * Reuse one instance: the configuration is read-only and no method keeps state
+ * between calls, so it is safe to share anywhere in a process.
+ *
+ * ```ts
+ * const client = new Client({ timeoutMs: 5_000, totalTimeoutMs: 15_000 });
+ * const address = await client.lookup("01001-000");
+ * ```
+ */
+export class Client {
+ /** The API root, without a trailing slash. */
+ readonly baseUrl: string;
+ /** Milliseconds allowed for a single attempt. */
+ readonly timeoutMs: number;
+ /** How many attempts in total, counting the first. */
+ readonly maxAttempts: number;
+ /** Base of the exponential wait, in milliseconds. */
+ readonly backoffBaseMs: number;
+ /** Cap on each individual wait, in milliseconds. */
+ readonly backoffMaxMs: number;
+ /** Cap on the whole call, in milliseconds, or `undefined` for none. */
+ readonly totalTimeoutMs?: number;
+ /** The `User-Agent` sent with every request. */
+ readonly userAgent: string;
+
+ /**
+ * @throws {RangeError} if `maxAttempts` is below 1, or a timeout is not
+ * positive.
+ */
+ constructor(options: ClientOptions = {}) {
+ const {
+ baseUrl = DEFAULT_URL,
+ timeoutMs = 10_000,
+ maxAttempts = 3,
+ backoffBaseMs = 500,
+ backoffMaxMs = 8_000,
+ totalTimeoutMs,
+ userAgent,
+ } = options;
+
+ if (!Number.isFinite(maxAttempts) || maxAttempts < 1) {
+ throw new RangeError("maxAttempts must be >= 1");
+ }
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
+ throw new RangeError("timeoutMs must be > 0");
+ }
+ if (totalTimeoutMs !== undefined && (!Number.isFinite(totalTimeoutMs) || totalTimeoutMs <= 0)) {
+ throw new RangeError("totalTimeoutMs must be > 0");
+ }
+
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
+ this.timeoutMs = timeoutMs;
+ this.maxAttempts = Math.trunc(maxAttempts);
+ this.backoffBaseMs = backoffBaseMs;
+ this.backoffMaxMs = backoffMaxMs;
+ this.totalTimeoutMs = totalTimeoutMs;
+ this.userAgent = userAgent ?? `cepis-typescript/${VERSION} (+https://cep.is)`;
+ }
+
+ /**
+ * Look up a single CEP.
+ *
+ * ```ts
+ * const address = await client.lookup("01001-000");
+ * address.localidade; // "São Paulo"
+ * ```
+ *
+ * A 400 from the API surfaces as {@link InvalidCEP} carrying the API's own
+ * message: the format already passed local validation, so the server knows
+ * something this client does not.
+ *
+ * @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.
+ * @throws {ServerError} 5xx after every attempt.
+ * @throws {NetworkError} the request never completed.
+ * @throws {InvalidResponse} a success status with an unreadable body.
+ */
+ async lookup(cep: string): Promise<Address> {
+ const number = normalizeCep(cep);
+ const reply = await this.request(`/${number}`);
+
+ if (reply.status === 404) {
+ throw new CEPNotFound(`CEP ${number} does not exist in the DNE database`, number);
+ }
+ if (reply.status === 400) {
+ throw new InvalidCEP(errorMessage(reply.body, `CEP ${number} rejected`));
+ }
+
+ return Address.fromJson(this.json(reply.body));
+ }
+
+ /**
+ * Look up many CEPs in one batch.
+ *
+ * Resolves to an array of **the same length and in the same order** as the
+ * input. A CEP that does not exist becomes `null` at its position — it does
+ * not vanish from the array, and it does not throw. That is the right
+ * difference from {@link Client.lookup}: in a batch of a thousand, one bad
+ * CEP must not cost the other nine hundred and ninety-nine.
+ *
+ * A malformed CEP also becomes `null`, for the same reason. To know *which*
+ * one was malformed, validate first with {@link normalizeCep}.
+ *
+ * Input is chunked into blocks of {@link MAX_BATCH} automatically.
+ *
+ * **Batches carry no coordinate.** The endpoint behind this is CSV, and the
+ * CSV has no `geo` column. Every `Address` returned here has `geo === null`,
+ * including for CEPs that do have one. Need `geo`? Use
+ * {@link Client.lookup}, one at a time.
+ */
+ async lookupMany(ceps: readonly string[]): Promise<(Address | null)[]> {
+ if (ceps.length === 0) {
+ return [];
+ }
+
+ const result: (Address | null)[] = [];
+ for (let start = 0; start < ceps.length; start += MAX_BATCH) {
+ result.push(...(await this.lookupChunk(ceps.slice(start, start + MAX_BATCH))));
+ }
+ return result;
+ }
+
+ /**
+ * Query `GET /health`: does the API answer, and with which database.
+ *
+ * Does not throw for an API reporting itself degraded — that arrives in
+ * `Health.status`, and checking health is exactly when you want the
+ * diagnosis rather than an exception. Network failures still throw.
+ */
+ async health(): Promise<Health> {
+ const reply = await this.request("/health", [200, 503]);
+ return Health.fromJson(this.json(reply.body));
+ }
+
+ private async lookupChunk(ceps: readonly string[]): Promise<(Address | null)[]> {
+ const query = new URLSearchParams({ ceps: ceps.join(",") }).toString();
+ const reply = await this.request(`/csv?${query}`);
+
+ const rows: (Address | null)[] = [];
+ for (const record of parseCsv(reply.body)) {
+ if ((record.localidade ?? "").trim() === "") {
+ rows.push(null);
+ continue;
+ }
+ rows.push(Address.fromJson(record));
+ }
+
+ while (rows.length < ceps.length) {
+ rows.push(null);
+ }
+ return rows.slice(0, ceps.length);
+ }
+
+ private json(body: string): RawObject {
+ let data: unknown;
+ try {
+ data = JSON.parse(body);
+ } catch (error) {
+ throw new InvalidResponse(
+ "response is not valid JSON — something between you and the API is " +
+ `answering instead of it: ${JSON.stringify(body.slice(0, 120))}`,
+ { cause: error },
+ );
+ }
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
+ throw new InvalidResponse(
+ `expected a JSON object, got ${Array.isArray(data) ? "array" : typeof data}`,
+ );
+ }
+ return data as RawObject;
+ }
+
+ private backoff(attempt: number, retryAfter: number | undefined): number {
+ if (retryAfter !== undefined) {
+ return Math.min(retryAfter, this.backoffMaxMs);
+ }
+ const ceiling = Math.min(this.backoffBaseMs * 2 ** (attempt - 1), this.backoffMaxMs);
+ return Math.random() * ceiling;
+ }
+
+ private async request(path: string, acceptedStatus: readonly number[] = [200]): Promise<Reply> {
+ const url = `${this.baseUrl}${path}`;
+ const deadline =
+ this.totalTimeoutMs === undefined ? undefined : performance.now() + this.totalTimeoutMs;
+
+ let lastStatus: number | undefined;
+ let lastRetryAfter: number | undefined;
+ let lastFailure: unknown;
+
+ for (let attempt = 1; attempt <= this.maxAttempts; attempt += 1) {
+ let budget = this.timeoutMs;
+ if (deadline !== undefined) {
+ const remaining = deadline - performance.now();
+ if (remaining <= 0) {
+ break;
+ }
+ budget = Math.min(budget, remaining);
+ }
+
+ try {
+ const response = await fetch(url, {
+ method: "GET",
+ redirect: "follow",
+ headers: {
+ "User-Agent": this.userAgent,
+ Accept: ACCEPT,
+ },
+ signal: AbortSignal.timeout(Math.max(1, Math.ceil(budget))),
+ });
+ const body = await response.text();
+
+ if (acceptedStatus.includes(response.status) || !RETRIABLE_STATUS.has(response.status)) {
+ return { status: response.status, body, headers: response.headers };
+ }
+
+ lastStatus = response.status;
+ lastRetryAfter = retryAfterMs(response.headers.get("Retry-After"));
+ lastFailure = undefined;
+ } catch (error) {
+ lastFailure = error;
+ lastStatus = undefined;
+ lastRetryAfter = undefined;
+ }
+
+ if (attempt === this.maxAttempts) {
+ break;
+ }
+
+ let wait = this.backoff(attempt, lastRetryAfter);
+ if (deadline !== undefined) {
+ const remaining = deadline - performance.now();
+ if (remaining <= 0) {
+ break;
+ }
+ wait = Math.min(wait, remaining);
+ }
+ await sleep(wait);
+ }
+
+ const suffix = `(after ${this.maxAttempts} attempt(s))`;
+ if (lastStatus === 429) {
+ throw new RateLimited(`rate limit reached at ${url} ${suffix}`, lastRetryAfter);
+ }
+ if (lastStatus !== undefined) {
+ throw new ServerError(`the API answered ${lastStatus} at ${url} ${suffix}`, lastStatus);
+ }
+ throw new NetworkError(`could not reach ${url} ${suffix}: ${describe(lastFailure)}`, {
+ cause: lastFailure,
+ });
+ }
+}
+
+function errorMessage(body: string, fallback: string): string {
+ 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;
+ }
+ }
+ } catch {
+ return fallback;
+ }
+ return fallback;
+}
diff --git a/src/csv.ts b/src/csv.ts
new file mode 100644
index 0000000..1a9431c
--- /dev/null
+++ b/src/csv.ts
@@ -0,0 +1,92 @@
+/**
+ * A minimal RFC 4180 reader, for the `/csv` batch endpoint.
+ *
+ * Values may contain commas and are then double-quoted with `""` escaping, so
+ * splitting on `,` corrupts every row that carries a street name like
+ * `"Rua Um, Dois"`. This is here because the platform ships no CSV parser and
+ * the library ships no dependencies.
+ *
+ * @module
+ */
+
+/** One parsed CSV record, keyed by the header row. */
+export type CsvRecord = Record<string, string>;
+
+function parseRows(text: string): string[][] {
+ const rows: string[][] = [];
+ let row: string[] = [];
+ let field = "";
+ let quoted = false;
+ let started = false;
+
+ for (let i = 0; i < text.length; i += 1) {
+ const char = text[i];
+
+ if (quoted) {
+ if (char === '"') {
+ if (text[i + 1] === '"') {
+ field += '"';
+ i += 1;
+ } else {
+ quoted = false;
+ }
+ } else {
+ field += char;
+ }
+ continue;
+ }
+
+ if (char === '"') {
+ quoted = true;
+ started = true;
+ } else if (char === ",") {
+ row.push(field);
+ field = "";
+ started = true;
+ } else if (char === "\n") {
+ row.push(field);
+ rows.push(row);
+ row = [];
+ field = "";
+ started = false;
+ } else if (char !== "\r") {
+ field += char;
+ started = true;
+ }
+ }
+
+ if (started || field !== "" || row.length > 0) {
+ row.push(field);
+ rows.push(row);
+ }
+
+ return rows;
+}
+
+/**
+ * Parse CSV text into records keyed by its header row.
+ *
+ * A leading byte-order mark is dropped, `CRLF` and `LF` are both accepted, and
+ * a row shorter than the header has its remaining columns filled with the
+ * empty string rather than `undefined`. An empty input yields no records.
+ */
+export function parseCsv(text: string): CsvRecord[] {
+ const rows = parseRows(text.replace(/^\uFEFF/, ""));
+ const header = rows.shift();
+ if (header === undefined) {
+ return [];
+ }
+
+ const records: CsvRecord[] = [];
+ for (const row of rows) {
+ if (row.length === 1 && row[0] === "") {
+ continue;
+ }
+ const record: CsvRecord = {};
+ for (let i = 0; i < header.length; i += 1) {
+ record[header[i] as string] = row[i] ?? "";
+ }
+ records.push(record);
+ }
+ return records;
+}
diff --git a/src/errors.ts b/src/errors.ts
new file mode 100644
index 0000000..4e5dbb2
--- /dev/null
+++ b/src/errors.ts
@@ -0,0 +1,125 @@
+/**
+ * Error hierarchy.
+ *
+ * Everything derives from {@link CepisError}, so callers who only care that
+ * *something* went wrong can catch one type.
+ *
+ * Below that, the split is by **the decision the caller has to make**, not by
+ * the HTTP status that caused it:
+ *
+ * - {@link InvalidCEP} and {@link CEPNotFound} are problems with the *request*.
+ * Retrying is pointless — the same CEP will fail again.
+ * - {@link NetworkError}, {@link ServerError} and {@link RateLimited} 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 error": the client also
+ * throws `InvalidCEP` without making any request at all, when the CEP is
+ * malformed before it ever leaves the machine.
+ *
+ * @module
+ */
+
+/**
+ * Base of every error this library throws. Catch it to handle any failure.
+ *
+ * `name` is set on every subclass, so an error that crosses a serialisation
+ * boundary (a worker, a log line, a JSON body) still says which one it was.
+ */
+export class CepisError extends Error {
+ constructor(message: string, options?: ErrorOptions) {
+ super(message, options);
+ this.name = "CepisError";
+ }
+}
+
+/**
+ * The CEP is not 8 digits.
+ *
+ * Usually thrown **without** a request ever being made — the client checks the
+ * format before going to the network. It is also what a 400 from the API
+ * becomes, carrying the API's own message.
+ */
+export class InvalidCEP extends CepisError {
+ constructor(message: string, options?: ErrorOptions) {
+ super(message, options);
+ this.name = "InvalidCEP";
+ }
+}
+
+/**
+ * The CEP is well-formed but does not exist in the DNE database.
+ *
+ * Never thrown by {@link Client.lookupMany}, which reports a missing CEP as
+ * `null` at its position instead.
+ */
+export class CEPNotFound extends CepisError {
+ /** The 8-digit CEP that was looked up. */
+ readonly cep: string;
+
+ constructor(message: string, cep: string, options?: ErrorOptions) {
+ super(message, options);
+ this.name = "CEPNotFound";
+ this.cep = cep;
+ }
+}
+
+/**
+ * The request never completed: DNS, refused connection, timeout, TLS.
+ *
+ * Thrown only after every configured attempt has failed. The underlying
+ * failure is kept in `cause`.
+ */
+export class NetworkError extends CepisError {
+ constructor(message: string, options?: ErrorOptions) {
+ super(message, options);
+ this.name = "NetworkError";
+ }
+}
+
+/** The API answered 5xx on every attempt. */
+export class ServerError extends CepisError {
+ /** The HTTP status seen on the last attempt. */
+ readonly status: number;
+
+ constructor(message: string, status: number, options?: ErrorOptions) {
+ super(message, options);
+ this.name = "ServerError";
+ this.status = status;
+ }
+}
+
+/**
+ * The API — or the edge in front of it — answered 429.
+ *
+ * The client already honoured `Retry-After` across the attempts it made; the
+ * value is exposed here for callers who want to reschedule the work.
+ */
+export class RateLimited extends CepisError {
+ /**
+ * What the server asked for, in **milliseconds**, or `undefined` if it did
+ * not say. Already clamped to nothing: it is the raw request, not the wait
+ * the client applied.
+ */
+ readonly retryAfterMs?: number;
+
+ constructor(message: string, retryAfterMs?: number, options?: ErrorOptions) {
+ super(message, options);
+ this.name = "RateLimited";
+ this.retryAfterMs = retryAfterMs;
+ }
+}
+
+/**
+ * A success status came back with a body that cannot be parsed.
+ *
+ * In practice this 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.
+ */
+export class InvalidResponse extends CepisError {
+ constructor(message: string, options?: ErrorOptions) {
+ super(message, options);
+ this.name = "InvalidResponse";
+ }
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..0ac9631
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,110 @@
+/**
+ * TypeScript client for the public CEP API at [cep.is](https://cep.is).
+ *
+ * Quick use — for a script, the module-level functions are enough:
+ *
+ * ```ts
+ * import { lookup } from "cepis";
+ *
+ * const address = await lookup("01001-000");
+ * console.log(address.localidade, address.uf); // São Paulo SP
+ * ```
+ *
+ * In an application, build a {@link Client} once and reuse it:
+ *
+ * ```ts
+ * import { Client } from "cepis";
+ *
+ * const client = new Client({ timeoutMs: 5_000, totalTimeoutMs: 15_000 });
+ * const address = await client.lookup("01001000");
+ * ```
+ *
+ * Pointing at a local instance:
+ *
+ * ```ts
+ * const client = new Client({ baseUrl: "http://localhost:8080" });
+ * ```
+ *
+ * Errors are explicit and split by decision — whether retrying helps or not:
+ *
+ * ```ts
+ * import { CEPNotFound, CepisError, lookup } from "cepis";
+ *
+ * try {
+ * const address = await lookup(cepFromForm);
+ * } catch (error) {
+ * if (error instanceof CEPNotFound) {
+ * // the CEP does not exist: ask the user for another
+ * } else if (error instanceof CepisError) {
+ * // network, server, rate limit: retry later
+ * } else {
+ * throw error;
+ * }
+ * }
+ * ```
+ *
+ * Note on field names: the API surface is English, but the **data fields keep
+ * the API's own Portuguese names** (`logradouro`, `bairro`, `localidade`).
+ * What you read in the JSON is what you access on the object.
+ *
+ * The module-level functions share one default client, built once because
+ * {@link Client} keeps no state between calls. Anyone needing different
+ * settings should build their own.
+ *
+ * @module
+ */
+
+import { Client } from "./client.js";
+import type { Address, Health } from "./models.js";
+
+export { Client, DEFAULT_URL, MAX_BATCH, normalizeCep, type ClientOptions } from "./client.js";
+export { parseCsv, type CsvRecord } from "./csv.js";
+export {
+ CEPNotFound,
+ CepisError,
+ InvalidCEP,
+ InvalidResponse,
+ NetworkError,
+ RateLimited,
+ ServerError,
+} from "./errors.js";
+export {
+ Address,
+ Geo,
+ Health,
+ type AddressFields,
+ type GeoFields,
+ type HealthFields,
+ type RawObject,
+} from "./models.js";
+export { VERSION } from "./version.js";
+
+const shared = new Client();
+
+/**
+ * Look up a CEP using the default configuration.
+ *
+ * Shorthand for `new Client().lookup(cep)`. See {@link Client.lookup}.
+ */
+export function lookup(cep: string): Promise<Address> {
+ return shared.lookup(cep);
+}
+
+/**
+ * Look up many CEPs in one batch, using the default configuration.
+ *
+ * Shorthand for {@link Client.lookupMany} — read that one, especially the
+ * warning that batches carry no coordinate.
+ */
+export function lookupMany(ceps: readonly string[]): Promise<(Address | null)[]> {
+ return shared.lookupMany(ceps);
+}
+
+/**
+ * Query the API's health using the default configuration.
+ *
+ * Shorthand for {@link Client.health}.
+ */
+export function health(): Promise<Health> {
+ return shared.health();
+}
diff --git a/src/models.ts b/src/models.ts
new file mode 100644
index 0000000..7e20b54
--- /dev/null
+++ b/src/models.ts
@@ -0,0 +1,300 @@
+/**
+ * The data the API returns, as objects.
+ *
+ * **Data field names are the API's own, in Portuguese** — `logradouro`,
+ * `bairro`, `localidade`. Everything else in this library is English; the data
+ * fields are the one deliberate exception.
+ *
+ * The reason is fidelity: what you read in the JSON at
+ * https://cep.is/01001000 is exactly what you access on the object.
+ * Translating to `street`/`city` would mean maintaining a field-by-field
+ * mapping and forcing anyone reading the API docs alongside this library to
+ * translate back in their head. The values are Portuguese either way — they
+ * come from the Correios database.
+ *
+ * @module
+ */
+
+/** A JSON object as it arrived from the API, before interpretation. */
+export type RawObject = Record<string, unknown>;
+
+function toNumber(value: unknown): number | null {
+ if (typeof value === "number") {
+ return Number.isFinite(value) ? value : null;
+ }
+ if (typeof value === "string" && value.trim() !== "") {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+ }
+ return null;
+}
+
+function toText(value: unknown): string {
+ if (value === undefined || value === null) {
+ return "";
+ }
+ return String(value);
+}
+
+/** The four values that make up a coordinate. */
+export interface GeoFields {
+ lat: number;
+ lon: number;
+ raio_m: number;
+ precisao: string;
+}
+
+/**
+ * The CEP's coordinate, when the database has one.
+ *
+ * The point is the **median** of the coordinates of that CEP's addresses, not
+ * the mean: one badly geocoded address 40 km away shifts a mean and does not
+ * move a median.
+ *
+ * Always check `precisao` before using `lat`/`lon` for distance:
+ *
+ * - `"logradouro"`: the point is a stretch of street. `raio_m` lands around
+ * 100–150 m. Good for maps and for distance.
+ * - `"localidade"`: the CEP *is* the whole municipality (a city-wide general
+ * CEP), and the point is its centroid. `raio_m` runs from 7 km to 40 km.
+ * Computing shipping from this is wrong by tens of kilometres.
+ *
+ * `raio_m` is the radius containing 90% of that CEP's addresses. It exists
+ * precisely so that error cannot pass unnoticed.
+ */
+export class Geo implements GeoFields {
+ /** Latitude, in decimal degrees. */
+ readonly lat: number;
+ /** Longitude, in decimal degrees. */
+ readonly lon: number;
+ /** Radius, in metres, containing 90% of that CEP's addresses. */
+ readonly raio_m: number;
+ /** Either `"logradouro"` (a street) or `"localidade"` (a municipality). */
+ readonly precisao: string;
+
+ constructor(fields: GeoFields) {
+ this.lat = fields.lat;
+ this.lon = fields.lon;
+ this.raio_m = fields.raio_m;
+ this.precisao = fields.precisao;
+ }
+
+ /**
+ * `true` when the coordinate points at a street, not a municipality.
+ *
+ * Shorthand for `precisao === "logradouro"`, which is the check to make
+ * before using the point to measure distance.
+ */
+ get isPrecise(): boolean {
+ return this.precisao === "logradouro";
+ }
+
+ /**
+ * Build from the `geo` block the API returned, or `null` when it cannot be
+ * read.
+ *
+ * A malformed block yields `null` rather than throwing: it is not worth
+ * losing the whole address over, since the rest of the response is still
+ * useful.
+ */
+ static fromJson(data: unknown): Geo | null {
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
+ return null;
+ }
+ const block = data as RawObject;
+ const lat = toNumber(block.lat);
+ const lon = toNumber(block.lon);
+ const raio = toNumber(block.raio_m);
+ if (lat === null || lon === null || raio === null) {
+ return null;
+ }
+ if (block.precisao === undefined || block.precisao === null) {
+ return null;
+ }
+ return new Geo({
+ lat,
+ lon,
+ raio_m: Math.trunc(raio),
+ precisao: String(block.precisao),
+ });
+ }
+}
+
+/** Every field of an {@link Address}, as plain data. */
+export interface AddressFields {
+ cep: string;
+ logradouro: string;
+ complemento: string;
+ unidade: string;
+ bairro: string;
+ localidade: string;
+ uf: string;
+ estado: string;
+ regiao: string;
+ ibge: string;
+ gia: string;
+ ddd: string;
+ siafi: string;
+ geo?: Geo | null;
+ raw?: RawObject;
+}
+
+/**
+ * A CEP and everything the database knows about it.
+ *
+ * Fields the database does not have for that CEP come back as empty strings,
+ * never `null` — that is what the API returns, and changing it here would
+ * create a difference between reading the JSON and reading this library.
+ *
+ * `geo` is the exception: it is `null` when there is no coordinate. There the
+ * difference is real, because the API **omits** the whole field in that case
+ * (about 43% of CEPs) rather than sending null.
+ *
+ * `raw` holds the response exactly as it arrived, uninterpreted, so a field
+ * the API grows before this library is updated is not lost.
+ */
+export class Address {
+ /** The 8 digits, always unmasked. */
+ readonly cep: string;
+ /** Street, avenue or square. Empty when the CEP covers a whole municipality. */
+ readonly logradouro: string;
+ /** Extra qualifier on the street, such as `"lado ímpar"`. */
+ readonly complemento: string;
+ /** Name of the large recipient the CEP belongs to, when it has one. */
+ readonly unidade: string;
+ /** Neighbourhood. */
+ readonly bairro: string;
+ /** Municipality. */
+ readonly localidade: string;
+ /** Two-letter state code, such as `"SP"`. */
+ 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;
+ /** GIA code, for São Paulo state. */
+ readonly gia: string;
+ /** Telephone area code. */
+ readonly ddd: string;
+ /** SIAFI municipality code. */
+ readonly siafi: string;
+ /**
+ * The coordinate, or `null` when the database has none.
+ *
+ * Always `null` for an address obtained through `Client.lookupMany` —
+ * batches carry no coordinate.
+ */
+ readonly geo: Geo | null;
+ /** The response as it arrived, including fields this library does not know. */
+ readonly raw: RawObject;
+
+ constructor(fields: AddressFields) {
+ this.cep = fields.cep;
+ this.logradouro = fields.logradouro;
+ this.complemento = fields.complemento;
+ this.unidade = fields.unidade;
+ this.bairro = fields.bairro;
+ this.localidade = fields.localidade;
+ this.uf = fields.uf;
+ this.estado = fields.estado;
+ this.regiao = fields.regiao;
+ this.ibge = fields.ibge;
+ this.gia = fields.gia;
+ this.ddd = fields.ddd;
+ this.siafi = fields.siafi;
+ this.geo = fields.geo ?? null;
+ this.raw = fields.raw ?? {};
+ }
+
+ /**
+ * The CEP with its mask: `01001-000`.
+ *
+ * The native contract always returns it unmasked; formatting is
+ * presentation, and it lives here so it does not become a repeated template
+ * literal in every project that uses this.
+ */
+ get formattedCep(): string {
+ return `${this.cep.slice(0, 5)}-${this.cep.slice(5)}`;
+ }
+
+ /**
+ * Build from the object the API returned.
+ *
+ * Unknown fields are ignored during construction but preserved in `raw`, so
+ * a new field in the API cannot break this library.
+ */
+ static fromJson(data: RawObject): Address {
+ return new Address({
+ cep: toText(data.cep),
+ logradouro: toText(data.logradouro),
+ complemento: toText(data.complemento),
+ unidade: toText(data.unidade),
+ bairro: toText(data.bairro),
+ localidade: toText(data.localidade),
+ uf: toText(data.uf),
+ estado: toText(data.estado),
+ regiao: toText(data.regiao),
+ ibge: toText(data.ibge),
+ gia: toText(data.gia),
+ ddd: toText(data.ddd),
+ siafi: toText(data.siafi),
+ geo: Geo.fromJson(data.geo),
+ raw: { ...data },
+ });
+ }
+}
+
+/** Every field of a {@link Health} report, as plain data. */
+export interface HealthFields {
+ status: string;
+ dne: string;
+ ceps: number;
+ geo: number;
+ base_gerada_em: string;
+}
+
+/**
+ * The response of `GET /health`.
+ *
+ * Good for two things: checking that the API answers, and knowing **which
+ * version of the database** is live. The DNE database changes every two weeks;
+ * `dne` identifies it.
+ */
+export class Health implements HealthFields {
+ /** `"ok"` when the API considers itself healthy. */
+ readonly status: string;
+ /** Identifier of the fortnightly DNE database currently loaded. */
+ readonly dne: string;
+ /** How many CEPs that database holds. */
+ readonly ceps: number;
+ /** How many of those carry a coordinate. */
+ readonly geo: number;
+ /** When the base was generated, as an ISO-8601 instant. */
+ readonly base_gerada_em: string;
+
+ constructor(fields: HealthFields) {
+ this.status = fields.status;
+ this.dne = fields.dne;
+ this.ceps = fields.ceps;
+ this.geo = fields.geo;
+ this.base_gerada_em = fields.base_gerada_em;
+ }
+
+ /** `true` when the API reports itself healthy. */
+ get ok(): boolean {
+ return this.status === "ok";
+ }
+
+ /** Build from the object the API returned. */
+ static fromJson(data: RawObject): Health {
+ return new Health({
+ status: toText(data.status),
+ dne: toText(data.dne),
+ ceps: toNumber(data.ceps) ?? 0,
+ geo: toNumber(data.geo) ?? 0,
+ base_gerada_em: toText(data.base_gerada_em),
+ });
+ }
+}
diff --git a/src/version.ts b/src/version.ts
new file mode 100644
index 0000000..b024a5d
--- /dev/null
+++ b/src/version.ts
@@ -0,0 +1,2 @@
+/** The library's version, as it appears in the default `User-Agent`. */
+export const VERSION = "0.1.0";