From 986a16c34049187a6c1f39f38bfa59e327093494 Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 12:15:55 -0300 Subject: Initial import --- .gitignore | 10 ++ .prettierignore | 3 + .prettierrc.json | 3 + LICENSE | 15 ++ Makefile | 39 +++++ README.md | 214 ++++++++++++++++++++++++ package.json | 50 ++++++ src/cli.ts | 140 ++++++++++++++++ src/client.ts | 433 +++++++++++++++++++++++++++++++++++++++++++++++ src/csv.ts | 92 ++++++++++ src/errors.ts | 125 ++++++++++++++ src/index.ts | 110 ++++++++++++ src/models.ts | 300 +++++++++++++++++++++++++++++++++ src/version.ts | 2 + test/cepis.test.ts | 474 ++++++++++++++++++++++++++++++++++++++++++++++++++++ test/server.ts | 97 +++++++++++ tsconfig.build.json | 12 ++ tsconfig.json | 24 +++ 18 files changed, 2143 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/client.ts create mode 100644 src/csv.ts create mode 100644 src/errors.ts create mode 100644 src/index.ts create mode 100644 src/models.ts create mode 100644 src/version.ts create mode 100644 test/cepis.test.ts create mode 100644 test/server.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json 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 +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5e9104d --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Murilo Ijanc + +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 ", + "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...] + + --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 { + 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 { + 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
{ + 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 { + 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 { + 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; + +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
{ + 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 { + 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; + +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 = { + 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: "captive portal", + }); + 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; +} + +/** Per-test programmable routes, plus a request counter. */ +export class Router { + private readonly routes = new Map(); + /** 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"] +} -- cgit v1.3