diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:15:55 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:15:55 -0300 |
| commit | 986a16c34049187a6c1f39f38bfa59e327093494 (patch) | |
| tree | 0c0bab4521fa3e8a4245b534505f56816a6f250b | |
| download | typescript-986a16c34049187a6c1f39f38bfa59e327093494.tar.gz | |
Initial importv0.1.0
| -rw-r--r-- | .gitignore | 10 | ||||
| -rw-r--r-- | .prettierignore | 3 | ||||
| -rw-r--r-- | .prettierrc.json | 3 | ||||
| -rw-r--r-- | LICENSE | 15 | ||||
| -rw-r--r-- | Makefile | 39 | ||||
| -rw-r--r-- | README.md | 214 | ||||
| -rw-r--r-- | package.json | 50 | ||||
| -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 | ||||
| -rw-r--r-- | test/cepis.test.ts | 474 | ||||
| -rw-r--r-- | test/server.ts | 97 | ||||
| -rw-r--r-- | tsconfig.build.json | 12 | ||||
| -rw-r--r-- | tsconfig.json | 24 |
18 files changed, 2143 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fce9c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +*.tgz +*.log +.DS_Store + +# Written by bun install. Not committed: this is a library, so it must resolve +# against whatever its dependents already have, and a lock file here would pin +# only one development machine. +bun.lock diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..190ddbf --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +bun.lock diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..de753c5 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "printWidth": 100 +} @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Murilo Ijanc <[email protected]> + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7991202 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +BUN ?= bun + +.DEFAULT_GOAL := help +.PHONY: help install test lint format build check clean + +help: + @echo 'Targets:' + @echo ' install install the development dependencies' + @echo ' test run the test suite (bun test)' + @echo ' lint tsc --noEmit + prettier --check (read-only)' + @echo ' format apply prettier (writes)' + @echo ' build bundle to dist/ and emit type declarations' + @echo ' check lint + test + build, in that order' + @echo ' clean remove build artefacts' + +node_modules: package.json + $(BUN) install + @touch node_modules + +install: node_modules + +test: node_modules + $(BUN) test + +lint: node_modules + $(BUN) x tsc -p tsconfig.json + $(BUN) x prettier --check . + +format: node_modules + $(BUN) x prettier --write . + +build: node_modules + rm -rf dist + $(BUN) run build + +check: lint test build + +clean: + rm -rf dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..009f7df --- /dev/null +++ b/README.md @@ -0,0 +1,214 @@ +# cepis + +TypeScript client for the [public CEP API at cep.is](https://cep.is). Official +Correios data (DNE), refreshed every two weeks. Works from JavaScript too. + +```ts +import { lookup } from "cepis"; + +const address = await lookup("01001-000"); +console.log(address.logradouro, address.localidade, address.uf); +// Praça da Sé São Paulo SP +``` + +## Installation + +```sh +bun add cepis +# or: npm install cepis +``` + +Zero runtime dependencies. Requires a runtime with `fetch` and +`AbortSignal.timeout`: Node 18+, Bun, Deno, or a browser. Ships ESM and type +declarations. + +## Usage + +### A single lookup + +`lookup` accepts the CEP masked or unmasked and resolves to an `Address`: + +```ts +import { lookup } from "cepis"; + +const a = await lookup("01310-100"); + +a.logradouro; // "Avenida Paulista" +a.localidade; // "São Paulo" +a.uf; // "SP" +a.ibge; // "3550308" +a.cep; // "01310100" (always unmasked) +a.formattedCep; // "01310-100" +``` + +Fields the database does not have for that CEP come back as empty strings, +never `null`. The data fields keep the API's own Portuguese names; everything +else — methods, types, errors — is English. + +### Coordinates + +About 57% of CEPs have a coordinate. When they do not, `geo` is `null`: + +```ts +const a = await lookup("01310100"); + +if (a.geo) { + a.geo.lat; // -23.567202 + a.geo.lon; // -46.649659 + a.geo.raio_m; // 1084 + a.geo.precisao; // "logradouro" +} +``` + +**Check `precisao` before computing distance.** There are two kinds of point, +and the difference between them is tens of kilometres: + +| `precisao` | typical radius | what the point is | +| ------------ | -------------- | ------------------------------------------------------ | +| `logradouro` | 110–140 m | the stretch of street — good for maps and distance | +| `localidade` | 7–40 km | the municipality centroid: the CEP _is_ the whole city | + +`a.geo.isPrecise` is the shorthand for `precisao === "logradouro"`: + +```ts +const cost = + a.geo && a.geo.isPrecise ? shippingFrom(a.geo.lat, a.geo.lon) : shippingFromMunicipality(a.ibge); +``` + +### Many CEPs at once + +Do not loop over `lookup`. Use `lookupMany`, which resolves up to 500 CEPs per +request: + +```ts +import { lookupMany } from "cepis"; + +const ceps = ["01001000", "99999999", "20040020"]; +const result = await lookupMany(ceps); + +ceps.forEach((requested, i) => { + const address = result[i]; + console.log(requested, address === null ? "not found" : address.localidade); +}); +``` + +The returned array has **the same length and the same order** as the input. A +CEP that does not exist, or is malformed, becomes `null` at its position — it +does not vanish, and it does not throw. + +Arrays longer than 500 are chunked automatically. + +> **Batches carry no coordinate.** The endpoint behind them is CSV, and the CSV +> has no `geo` column. Every `Address` from `lookupMany` has `geo === null`, +> including for CEPs that do have one. Need `geo`? Use `lookup`, one at a time. + +### API health + +Useful for two things: checking the API answers, and knowing **which version of +the database** is live: + +```ts +import { health } from "cepis"; + +const h = await health(); +h.ok; // true +h.dne; // "26071" identifier of the fortnightly database +h.ceps; // 1596168 +h.base_gerada_em; // "2026-07-24T03:48:28Z" +``` + +An API reporting itself degraded does not throw — that arrives in `h.status`. + +## Configuration + +For anything beyond the defaults, build a `Client` and reuse it. It keeps no +state between calls: + +```ts +import { Client } from "cepis"; + +const client = new Client({ + baseUrl: "https://cep.is", // point at another instance + timeoutMs: 10_000, // per attempt, request and body read included + maxAttempts: 3, // counting the first; 1 disables retrying + backoffBaseMs: 500, // base of the exponential wait + backoffMaxMs: 8_000, // cap on each wait + totalTimeoutMs: undefined, // cap on the whole call, attempts included +}); + +const address = await client.lookup("01001000"); +``` + +Retries cover 429, 5xx and network failures, with exponential backoff and +jitter; 400 and 404 are never retried. + +The module-level functions (`lookup`, `lookupMany`, `health`) use a shared +default client. Need a different configuration? Build your own. + +### Pointing at a local instance + +`baseUrl` is the only option that usually changes between environments. The API +repo brings a local instance up with `just up`: + +```ts +const client = new Client({ baseUrl: "http://localhost:8080" }); +``` + +From the command line, to confirm quickly that it answers: + +```sh +bunx cepis --base-url http://localhost:8080 01001000 +``` + +## Errors + +Everything derives from `CepisError`. Below that, the split is by **the decision +you have to make**, not by the HTTP status that caused it: + +| error | when | does retrying help? | +| ----------------- | -------------------------------- | ------------------- | +| `InvalidCEP` | not 8 digits | no | +| `CEPNotFound` | well-formed, not in the database | no | +| `RateLimited` | 429 | yes, later | +| `ServerError` | 5xx | yes, later | +| `NetworkError` | DNS, connection, timeout, TLS | yes, later | +| `InvalidResponse` | unintelligible response | probably not | + +```ts +import { CEPNotFound, CepisError, lookup } from "cepis"; + +try { + const address = await lookup(cepFromForm); +} catch (error) { + if (error instanceof CEPNotFound) { + // ask the user for another CEP + } else if (error instanceof CepisError) { + // transient failure: reschedule + } else { + throw error; + } +} +``` + +`RateLimited` exposes `retryAfterMs` and `ServerError` exposes `status`. + +`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. + +## Development + +Everything runs through `make`, which shells out to `bun`: + +```sh +make # list the targets +make test # bun test +make lint # tsc --noEmit + prettier --check, touches nothing +make format # apply prettier +make build # bundle to dist/ and emit .d.ts +make check # lint, then test, then build +``` + +## Licence + +ISC. See [LICENSE](LICENSE). diff --git a/package.json b/package.json new file mode 100644 index 0000000..5958e69 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "cepis", + "version": "0.1.0", + "description": "TypeScript client for the public CEP API at cep.is — official Correios data (DNE)", + "keywords": [ + "cep", + "correios", + "brasil", + "brazil", + "postal-code", + "address", + "geocoding" + ], + "homepage": "https://cep.is", + "license": "ISC", + "author": "Murilo Ijanc <[email protected]>", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "bin": { + "cepis": "./dist/cli.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "test": "bun test", + "lint": "tsc -p tsconfig.json && prettier --check .", + "format": "prettier --write .", + "build": "bun build ./src/index.ts ./src/cli.ts --outdir dist --target node --format esm --sourcemap=linked && tsc -p tsconfig.build.json" + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "prettier": "^3.9.6", + "typescript": "^7.0.2" + } +} 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"; diff --git a/test/cepis.test.ts b/test/cepis.test.ts new file mode 100644 index 0000000..911aa1d --- /dev/null +++ b/test/cepis.test.ts @@ -0,0 +1,474 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { + Address, + CEPNotFound, + CepisError, + Client, + InvalidCEP, + InvalidResponse, + NetworkError, + RateLimited, + ServerError, + normalizeCep, + parseCsv, +} from "../src/index.js"; +import { startServer, type FakeServer } from "./server.js"; + +const SE_ADDRESS: Record<string, unknown> = { + cep: "01001000", + logradouro: "Praça da Sé", + complemento: "lado ímpar", + unidade: "", + bairro: "Sé", + localidade: "São Paulo", + uf: "SP", + estado: "São Paulo", + regiao: "Sudeste", + ibge: "3550308", + gia: "1004", + ddd: "11", + siafi: "7107", +}; + +const CSV_HEADER = + "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"; + +function csv(rows: readonly string[]): string { + return `${CSV_HEADER}\n${rows.join("\n")}\n`; +} + +function batchPath(ceps: readonly string[]): string { + return `/csv?${new URLSearchParams({ ceps: ceps.join(",") }).toString()}`; +} + +let server: FakeServer; +let client: Client; + +beforeEach(() => { + server = startServer(); + client = new Client({ + baseUrl: server.url, + timeoutMs: 5_000, + backoffBaseMs: 1, + backoffMaxMs: 10, + }); +}); + +afterEach(() => { + server.stop(); +}); + +describe("normalizeCep", () => { + test("accepts masked and unmasked", () => { + for (const value of ["01001000", "01001-000", " 01001-000 ", "01001.000"]) { + expect(normalizeCep(value)).toBe("01001000"); + } + }); + + test("rejects a bad format", () => { + for (const value of ["", "123", "0100100a", "010010000", "abcdefgh"]) { + expect(() => normalizeCep(value)).toThrow(InvalidCEP); + } + }); + + test("rejects a value that is not a string", () => { + expect(() => normalizeCep(1001000 as unknown as string)).toThrow(InvalidCEP); + }); + + test("throws something a single handler can catch", () => { + expect(() => normalizeCep("123")).toThrow(CepisError); + }); +}); + +describe("lookup", () => { + test("reads a successful lookup", async () => { + server.routes.respond("/01001000", { + status: 200, + body: JSON.stringify(SE_ADDRESS), + }); + const address = await client.lookup("01001-000"); + expect(address.localidade).toBe("São Paulo"); + expect(address.uf).toBe("SP"); + expect(address.formattedCep).toBe("01001-000"); + expect(address.geo).toBeNull(); + }); + + test("reads the coordinate when present", async () => { + const body = { + ...SE_ADDRESS, + geo: { lat: -23.5, lon: -46.6, raio_m: 120, precisao: "logradouro" }, + }; + server.routes.respond("/01310100", { + status: 200, + body: JSON.stringify(body), + }); + const address = await client.lookup("01310100"); + expect(address.geo).not.toBeNull(); + expect(address.geo?.lat).toBeCloseTo(-23.5); + expect(address.geo?.raio_m).toBe(120); + expect(address.geo?.isPrecise).toBe(true); + }); + + test("a municipality coordinate is not precise", async () => { + const body = { + ...SE_ADDRESS, + geo: { lat: -23.5, lon: -46.6, raio_m: 22000, precisao: "localidade" }, + }; + server.routes.respond("/01001000", { + status: 200, + body: JSON.stringify(body), + }); + const address = await client.lookup("01001000"); + expect(address.geo?.isPrecise).toBe(false); + }); + + test("a malformed geo block does not lose the address", async () => { + const body = { ...SE_ADDRESS, geo: { lat: "not a number" } }; + server.routes.respond("/01001000", { + status: 200, + body: JSON.stringify(body), + }); + const address = await client.lookup("01001000"); + expect(address.geo).toBeNull(); + expect(address.localidade).toBe("São Paulo"); + }); + + test("a new API field does not break the client", async () => { + const body = { ...SE_ADDRESS, campo_do_futuro: "value" }; + server.routes.respond("/01001000", { + status: 200, + body: JSON.stringify(body), + }); + const address = await client.lookup("01001000"); + expect(address.raw.campo_do_futuro).toBe("value"); + expect(address.localidade).toBe("São Paulo"); + }); + + test("404 becomes CEPNotFound", async () => { + server.routes.respond("/99999999", { + status: 404, + body: JSON.stringify({ erro: "cep_nao_encontrado", mensagem: "gone" }), + }); + const promise = client.lookup("99999999"); + await expect(promise).rejects.toBeInstanceOf(CEPNotFound); + await expect(promise).rejects.toThrow("99999999"); + }); + + test("404 is not retried", async () => { + server.routes.respond("/99999999", { + status: 404, + body: JSON.stringify({ erro: "x" }), + }); + await expect(client.lookup("99999999")).rejects.toBeInstanceOf(CEPNotFound); + expect(server.routes.count("/99999999")).toBe(1); + }); + + test("an invalid CEP never leaves the machine", async () => { + await expect(client.lookup("123")).rejects.toBeInstanceOf(InvalidCEP); + expect(server.routes.calls.length).toBe(0); + }); + + test("400 becomes InvalidCEP carrying the API's message", async () => { + server.routes.respond("/00000000", { + status: 400, + body: JSON.stringify({ + erro: "cep_invalido", + mensagem: "rejected by the API", + }), + }); + await expect(client.lookup("00000000")).rejects.toThrow("rejected by the API"); + }); + + test("400 is not retried", async () => { + server.routes.respond("/00000000", { + status: 400, + body: JSON.stringify({ erro: "x" }), + }); + await expect(client.lookup("00000000")).rejects.toBeInstanceOf(InvalidCEP); + expect(server.routes.count("/00000000")).toBe(1); + }); + + test("a response that is not JSON becomes InvalidResponse", async () => { + server.routes.respond("/01001000", { + status: 200, + body: "<html>captive portal</html>", + }); + await expect(client.lookup("01001000")).rejects.toBeInstanceOf(InvalidResponse); + }); + + test("a JSON array is not an address", async () => { + server.routes.respond("/01001000", { status: 200, body: "[]" }); + await expect(client.lookup("01001000")).rejects.toBeInstanceOf(InvalidResponse); + }); + + test("sends a User-Agent identifying the library", async () => { + const seen: string[] = []; + const probe = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + seen.push(request.headers.get("User-Agent") ?? ""); + return new Response(JSON.stringify(SE_ADDRESS)); + }, + }); + try { + await new Client({ baseUrl: `http://127.0.0.1:${probe.port}` }).lookup("01001000"); + } finally { + probe.stop(true); + } + expect(seen[0]).toContain("cepis-typescript/0.1.0"); + }); +}); + +describe("retrying", () => { + test("retries a 500 and then succeeds", async () => { + server.routes.respond("/01001000", [ + { status: 500, body: JSON.stringify({ erro: "interno" }) }, + { status: 200, body: JSON.stringify(SE_ADDRESS) }, + ]); + const address = await client.lookup("01001000"); + expect(address.localidade).toBe("São Paulo"); + expect(server.routes.count("/01001000")).toBe(2); + }); + + test("exhausts the attempts and then throws", async () => { + server.routes.respond("/01001000", { + status: 503, + body: JSON.stringify({ erro: "interno" }), + }); + try { + await client.lookup("01001000"); + throw new Error("expected ServerError"); + } catch (error) { + expect(error).toBeInstanceOf(ServerError); + expect((error as ServerError).status).toBe(503); + } + expect(server.routes.count("/01001000")).toBe(3); + }); + + test("maxAttempts of 1 disables retrying", async () => { + const single = new Client({ + baseUrl: server.url, + maxAttempts: 1, + timeoutMs: 5_000, + }); + server.routes.respond("/01001000", { status: 500, body: "{}" }); + await expect(single.lookup("01001000")).rejects.toBeInstanceOf(ServerError); + expect(server.routes.count("/01001000")).toBe(1); + }); + + test("429 becomes RateLimited exposing Retry-After", async () => { + server.routes.respond("/01001000", { + status: 429, + body: "{}", + headers: { "Retry-After": "7" }, + }); + try { + await client.lookup("01001000"); + throw new Error("expected RateLimited"); + } catch (error) { + expect(error).toBeInstanceOf(RateLimited); + expect((error as RateLimited).retryAfterMs).toBe(7_000); + } + }); + + test("an HTTP-date Retry-After is understood", async () => { + const when = new Date(Date.now() + 4_000).toUTCString(); + server.routes.respond("/01001000", { + status: 429, + body: "{}", + headers: { "Retry-After": when }, + }); + try { + await client.lookup("01001000"); + throw new Error("expected RateLimited"); + } catch (error) { + expect(error).toBeInstanceOf(RateLimited); + expect((error as RateLimited).retryAfterMs).toBeGreaterThan(0); + expect((error as RateLimited).retryAfterMs).toBeLessThanOrEqual(5_000); + } + }); + + test("an absurd Retry-After is capped", async () => { + server.routes.respond("/01001000", { + status: 429, + body: "{}", + headers: { "Retry-After": "3600" }, + }); + const started = performance.now(); + await expect(client.lookup("01001000")).rejects.toBeInstanceOf(RateLimited); + expect(performance.now() - started).toBeLessThan(5_000); + }); + + test("nothing listening becomes NetworkError", async () => { + const unreachable = new Client({ + baseUrl: "http://127.0.0.1:1", + maxAttempts: 2, + backoffBaseMs: 1, + timeoutMs: 1_000, + }); + await expect(unreachable.lookup("01001000")).rejects.toBeInstanceOf(NetworkError); + }); + + test("totalTimeoutMs bounds the whole call", async () => { + const bounded = new Client({ + baseUrl: server.url, + maxAttempts: 50, + backoffBaseMs: 50, + backoffMaxMs: 50, + totalTimeoutMs: 300, + }); + server.routes.respond("/01001000", { status: 500, body: "{}" }); + const started = performance.now(); + await expect(bounded.lookup("01001000")).rejects.toBeInstanceOf(ServerError); + expect(performance.now() - started).toBeLessThan(3_000); + expect(server.routes.count("/01001000")).toBeLessThan(50); + }); +}); + +describe("lookupMany", () => { + test("preserves order and marks a missing CEP", async () => { + const ceps = ["01001000", "99999999", "20040020"]; + server.routes.respond(batchPath(ceps), { + status: 200, + body: csv([ + "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107", + "99999999,,,,,,,,,,,,", + "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001", + ]), + }); + + const result = await client.lookupMany(ceps); + expect(result.length).toBe(3); + expect(result[0]?.localidade).toBe("São Paulo"); + expect(result[1]).toBeNull(); + expect(result[2]?.uf).toBe("RJ"); + }); + + test("a batch never carries a coordinate", async () => { + const ceps = ["01001000"]; + server.routes.respond(batchPath(ceps), { + status: 200, + body: csv(["01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107"]), + }); + const result = await client.lookupMany(ceps); + expect(result[0]).toBeInstanceOf(Address); + expect(result[0]?.geo).toBeNull(); + }); + + test("a field containing a comma is parsed", async () => { + const ceps = ["01001000"]; + server.routes.respond(batchPath(ceps), { + status: 200, + body: csv(['01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1']), + }); + const result = await client.lookupMany(ceps); + expect(result[0]?.logradouro).toBe("Rua Um, Dois"); + }); + + test("an empty input makes no request", async () => { + expect(await client.lookupMany([])).toEqual([]); + expect(server.routes.calls.length).toBe(0); + }); + + test("chunks above the API limit", async () => { + const ceps = Array.from({ length: 501 }, (_, i) => String(i).padStart(8, "0")); + server.routes.fallback = (path) => { + const requested = new URL(`http://x${path}`).searchParams.get("ceps") ?? ""; + const rows = requested + .split(",") + .map((cep) => `${cep},,,,,Cidade,SP,São Paulo,Sudeste,1,,11,1`); + return { status: 200, body: csv(rows) }; + }; + + const result = await client.lookupMany(ceps); + expect(server.routes.calls.length).toBe(2); + expect(result.length).toBe(501); + expect(result[500]?.cep).toBe("00000500"); + }); + + test("a truncated response pads rather than shifts", async () => { + const ceps = ["01001000", "20040020"]; + server.routes.respond(batchPath(ceps), { + status: 200, + body: csv(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"]), + }); + const result = await client.lookupMany(ceps); + expect(result.length).toBe(2); + expect(result[0]?.localidade).toBe("São Paulo"); + expect(result[1]).toBeNull(); + }); + + test("a malformed CEP is null, not an error", async () => { + const ceps = ["01001000", "nope"]; + server.routes.respond(batchPath(ceps), { + status: 200, + body: csv(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", "nope,,,,,,,,,,,,"]), + }); + const result = await client.lookupMany(ceps); + expect(result[1]).toBeNull(); + }); +}); + +describe("health", () => { + test("reads the report", async () => { + server.routes.respond("/health", { + status: 200, + body: JSON.stringify({ + status: "ok", + dne: "26071", + ceps: 1596168, + geo: 894489, + base_gerada_em: "2026-07-24T03:48:28Z", + }), + }); + const report = await client.health(); + expect(report.ok).toBe(true); + expect(report.dne).toBe("26071"); + expect(report.ceps).toBe(1596168); + expect(report.base_gerada_em).toBe("2026-07-24T03:48:28Z"); + }); + + test("a degraded API does not throw", async () => { + server.routes.respond("/health", { + status: 503, + body: JSON.stringify({ status: "degradado", dne: "", ceps: 0, geo: 0 }), + }); + const report = await client.health(); + expect(report.ok).toBe(false); + expect(report.status).toBe("degradado"); + expect(server.routes.count("/health")).toBe(1); + }); +}); + +describe("configuration", () => { + test("a trailing slash is not duplicated", () => { + expect(new Client({ baseUrl: "http://x/" }).baseUrl).toBe("http://x"); + }); + + test("rejects impossible settings", () => { + expect(() => new Client({ maxAttempts: 0 })).toThrow(RangeError); + expect(() => new Client({ timeoutMs: 0 })).toThrow(RangeError); + expect(() => new Client({ totalTimeoutMs: -1 })).toThrow(RangeError); + }); + + test("the default User-Agent identifies the library", () => { + expect(new Client().userAgent).toContain("cepis-typescript/"); + }); +}); + +describe("parseCsv", () => { + test("handles quotes, escapes and CRLF", () => { + const records = parseCsv('a,b\r\n"x,1","he said ""hi"""\r\n'); + expect(records).toEqual([{ a: "x,1", b: 'he said "hi"' }]); + }); + + test("fills columns a short row does not have", () => { + expect(parseCsv("a,b,c\n1\n")).toEqual([{ a: "1", b: "", c: "" }]); + }); + + test("an empty input yields no records", () => { + expect(parseCsv("")).toEqual([]); + }); +}); diff --git a/test/server.ts b/test/server.ts new file mode 100644 index 0000000..c242f52 --- /dev/null +++ b/test/server.ts @@ -0,0 +1,97 @@ +/** + * A throwaway HTTP server on a random loopback port. + * + * The suite points a real {@link Client} at a real socket: no test touches the + * network, and no test stubs `fetch`. The code path exercised is the one that + * runs in production, request construction and error body reading included. + * + * It doubles as the test that `baseUrl` works — if it did not, none of this + * would run. + * + * @module + */ + +/** What the fake server should answer for one request. */ +export interface Reply { + status: number; + body: string; + headers?: Record<string, string>; +} + +/** Per-test programmable routes, plus a request counter. */ +export class Router { + private readonly routes = new Map<string, Reply | Reply[]>(); + /** Every path requested so far, in order. */ + readonly calls: string[] = []; + /** Answers any path with no registered route. */ + fallback?: (path: string) => Reply; + + /** + * Register the answer for a path. + * + * An array makes each request consume the next entry, which is how retrying + * is tested. The last entry repeats once the array runs out. + */ + respond(path: string, reply: Reply | Reply[]): void { + this.routes.set(path, reply); + } + + /** Record the call and return the answer registered for `path`. */ + next(path: string): Reply { + this.calls.push(path); + const reply = this.routes.get(path); + if (reply === undefined) { + if (this.fallback !== undefined) { + return this.fallback(path); + } + return { + status: 404, + body: JSON.stringify({ erro: "cep_nao_encontrado" }), + }; + } + if (Array.isArray(reply)) { + return (reply.length > 1 ? reply.shift() : reply[0]) as Reply; + } + return reply; + } + + /** How many requests so far began with `prefix`. */ + count(prefix: string): number { + return this.calls.filter((call) => call.startsWith(prefix)).length; + } +} + +/** A running fake API, with its router. */ +export interface FakeServer { + url: string; + routes: Router; + stop: () => void; +} + +/** Start a fake API on `127.0.0.1` and a port the kernel picks. */ +export function startServer(): FakeServer { + const routes = new Router(); + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const url = new URL(request.url); + const reply = routes.next(`${url.pathname}${url.search}`); + return new Response(reply.body, { + status: reply.status, + headers: { + "Content-Type": "application/json; charset=utf-8", + ...reply.headers, + }, + }); + }, + }); + + return { + url: `http://127.0.0.1:${server.port}`, + routes, + stop: () => { + server.stop(true); + }, + }; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..89bc68b --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..af320f9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "types": ["bun"], + + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + + "noEmit": true + }, + "include": ["src", "test"] +} |