diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/cli.ts | 140 | ||||
| -rw-r--r-- | src/client.ts | 433 | ||||
| -rw-r--r-- | src/csv.ts | 92 | ||||
| -rw-r--r-- | src/errors.ts | 125 | ||||
| -rw-r--r-- | src/index.ts | 110 | ||||
| -rw-r--r-- | src/models.ts | 300 | ||||
| -rw-r--r-- | src/version.ts | 2 |
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"; |