diff --git a/spec/bridge/README.md b/spec/bridge/README.md index 964fc225..87764939 100644 --- a/spec/bridge/README.md +++ b/spec/bridge/README.md @@ -146,10 +146,11 @@ be handed it, so nothing is skipped for being inconvenient, and the count is vis ## Ported so far -| utility | what makes it worth porting | -| ------------- | ----------------------------------------------------------------- | -| `isValidCnpj` | regular expressions, check digits, JavaScript's own type coercion | -| `formatCnpj` | a shared mask helper, and three options that interact | +| utility | what makes it worth porting | +| --------------------- | ----------------------------------------------------------------- | +| `isValidCnpj` | regular expressions, check digits, JavaScript's own type coercion | +| `formatCnpj` | a shared mask helper, and three options that interact | +| `getAddressInfoByCep` | HTTP, JSON, retries, an error hierarchy, three providers raced | ## Adding a utility diff --git a/spec/bridge/conformance/cases/get-address-info-by-cep.ts b/spec/bridge/conformance/cases/get-address-info-by-cep.ts new file mode 100644 index 00000000..d9da982c --- /dev/null +++ b/spec/bridge/conformance/cases/get-address-info-by-cep.ts @@ -0,0 +1,266 @@ +/** + * What `getAddressInfoByCep` is replayed with, and the mock the other targets talk to. + * + * One table drives both sides. Recording stubs `fetch`, exactly as the JavaScript suite does; + * replaying serves the same scenarios over real HTTP, so the generated Go, Rust, Ruby, Java, + * C# and Python each run through their own client against something a real client can talk to. + * + * A scenario is keyed by the CEP, so no target needs to be told which one it is in: the CEP in + * the URL is the whole protocol. + */ +import { createServer } from "node:http"; + +import { type Recorder } from "../cases.ts"; + +/** What one provider answers in one scenario. `fail` is a transport failure, not an answer. */ +type Answer = { status: number; body: unknown } | { fail: true }; + +type Scenario = { viacep: Answer; widenet: Answer; brasilapi: Answer }; + +const address = (cep: string): Record => ({ + bairro: "Bela Vista", + cep, + localidade: "São Paulo", + logradouro: "Avenida Paulista", + uf: "SP", +}); + +const widenetAddress = (cep: string): Record => ({ + address: "Avenida Paulista", + city: "São Paulo", + code: cep, + district: "Bela Vista", + ok: true, + state: "SP", + status: 200, +}); + +const brasilApiAddress = (cep: string): Record => ({ + cep, + city: "São Paulo", + neighborhood: "Bela Vista", + state: "SP", + street: "Avenida Paulista", +}); + +const NOT_FOUND_BODY = { errors: [{ message: "CEP não encontrado" }] }; + +/** + * The scenarios, by CEP. Every provider that succeeds in a scenario answers the same address, + * so which one wins the race cannot change the expected result. + */ +const SCENARIOS: Record = { + // Every provider answers. + "01310100": { + viacep: { status: 200, body: address("01310-100") }, + widenet: { status: 200, body: widenetAddress("01310-100") }, + brasilapi: { status: 200, body: brasilApiAddress("01310100") }, + }, + // Every provider reports a miss, each in its own way. + "00000000": { + viacep: { status: 200, body: { erro: true } }, + widenet: { status: 200, body: { ok: false, status: 404 } }, + brasilapi: { status: 200, body: NOT_FOUND_BODY }, + }, + // Every provider answers an HTTP error. + "00000001": { + viacep: { status: 500, body: {} }, + widenet: { status: 503, body: {} }, + brasilapi: { status: 500, body: {} }, + }, + // Nothing reaches any server. + "00000002": { viacep: { fail: true }, widenet: { fail: true }, brasilapi: { fail: true } }, + // Only BrasilAPI answers, and it is the one the caller gets. + "00000003": { + viacep: { fail: true }, + widenet: { status: 503, body: {} }, + brasilapi: { status: 200, body: brasilApiAddress("00000003") }, + }, + // BrasilAPI reports the miss with a status, the others are simply down: not found wins. + "00000004": { + viacep: { status: 500, body: {} }, + widenet: { status: 503, body: {} }, + brasilapi: { status: 404, body: NOT_FOUND_BODY }, + }, + // A body that is not an object at all. + "00000005": { + viacep: { status: 200, body: "oops" }, + widenet: { status: 200, body: 42 }, + brasilapi: { status: 200, body: null }, + }, + // The optional fields are missing. + "00000006": { + viacep: { status: 200, body: { cep: "00000006" } }, + widenet: { status: 200, body: { code: "00000006", ok: true, status: 200 } }, + brasilapi: { status: 200, body: { cep: "00000006" } }, + }, + // ViaCEP answers fields that are not strings. + "00000007": { + viacep: { status: 200, body: { ...address("00000007"), localidade: null, uf: 12 } }, + widenet: { status: 503, body: {} }, + brasilapi: { status: 500, body: {} }, + }, + // The CEP comes back masked and has to be stripped. + "00000008": { + viacep: { status: 200, body: address("00000-008") }, + widenet: { status: 200, body: widenetAddress("00000-008") }, + brasilapi: { status: 200, body: brasilApiAddress("00000-008") }, + }, + // An empty CEP in the body is a miss, whatever else the body holds. + "00000009": { + viacep: { status: 200, body: { ...address(""), cep: "" } }, + widenet: { status: 200, body: { ...widenetAddress(""), code: "" } }, + brasilapi: { status: 200, body: { ...brasilApiAddress(""), cep: "" } }, + }, + // Widenet's own flags disagree with its body. + "00000010": { + viacep: { status: 500, body: {} }, + widenet: { status: 200, body: { code: "00000010", ok: false, status: 200 } }, + brasilapi: { status: 500, body: {} }, + }, + "00000011": { + viacep: { status: 500, body: {} }, + widenet: { status: 200, body: { code: "00000011", ok: true, status: 404 } }, + brasilapi: { status: 500, body: {} }, + }, + // A miss reported alongside a CEP is still a miss. + "00000012": { + viacep: { status: 200, body: { cep: "00000-012", erro: true } }, + widenet: { status: 503, body: {} }, + brasilapi: { status: 200, body: { ...brasilApiAddress("00000012"), ...NOT_FOUND_BODY } }, + }, + // One provider misses and the other is down: the miss is what the caller hears about. + "00000013": { + viacep: { status: 200, body: { erro: true } }, + widenet: { fail: true }, + brasilapi: { fail: true }, + }, +}; + +/** The provider lists worth trying against every scenario. */ +const EVERY_PROVIDER: (string[] | undefined)[] = [ + undefined, + ["viacep"], + ["widenet"], + ["brasilapi"], + ["viacep", "brasilapi"], + ["viacep", "widenet", "brasilapi"], +]; + +/** The answer a provider gives for a CEP, or a 404 when the scenario does not know it. */ +const answerFor = (provider: keyof Scenario, cep: string): Answer => { + const scenario = SCENARIOS[cep]; + + if (scenario === undefined) return { status: 404, body: NOT_FOUND_BODY }; + + return scenario[provider]; +}; + +/** Which provider a URL belongs to. */ +const providerOf = (path: string): keyof Scenario | undefined => { + if (path.includes("viacep.com.br")) return "viacep"; + if (path.includes("widenet.com.br")) return "widenet"; + if (path.includes("brasilapi.com.br")) return "brasilapi"; + + return undefined; +}; + +/** The CEP a URL asks about. */ +const cepOf = (path: string): string => path.replaceAll(/\D/g, "").slice(-8); + +/** The answer for a URL, whichever side is asking. */ +const answerForUrl = (url: string): Answer => { + const provider = providerOf(url); + + return provider === undefined ? { status: 404, body: {} } : answerFor(provider, cepOf(url)); +}; + +export const recorder: Recorder = { + module: "get-address-info-by-cep", + + inputs: () => { + const found: unknown[][] = []; + + for (const cep of Object.keys(SCENARIOS)) + for (const providers of EVERY_PROVIDER) + found.push(providers === undefined ? [cep] : [cep, { providers }]); + + // The inputs that never reach a provider. + for (const cep of ["12345", "123456789", "", "1310100", "abc", "0131010a"]) found.push([cep]); + + // A mask is stripped, and a number is padded; both land on the first scenario. + found.push( + ["01310-100"], + ["01.310-100"], + [1_310_100], + [1_310_100, { providers: ["brasilapi"] }], + ); + + // Provider lists that name nothing usable. + for (const providers of [[], ["invalid"], ["constructor", "toString"], ["VIACEP"]]) + found.push(["01310100", { providers }]); + + // An unknown name among known ones is dropped rather than fatal. + found.push(["01310100", { providers: ["viacep", "invalid", "brasilapi"] }]); + + // What an untyped JavaScript caller can still pass where a list of names is declared. + for (const providers of [null, "viacep", 5, {}, true]) found.push(["01310100", { providers }]); + + return found; + }, + + call: async (shipped, fn, args) => { + const realFetch = globalThis.fetch; + + globalThis.fetch = ((input: unknown): Promise => { + const answer = answerForUrl(String(input)); + + if ("fail" in answer) { + // The shipped `fetchWithRetry` retries a transient transport failure, so the stub + // reports one the same way the mock server does, by closing the socket. + const error = new Error("fetch failed"); + + (error as { cause?: unknown }).cause = { code: "ECONNRESET" }; + + return Promise.reject(error); + } + + return Promise.resolve({ + ok: answer.status >= 200 && answer.status < 300, + status: answer.status, + json: () => Promise.resolve(answer.body), + }); + }) as typeof fetch; + + try { + const answered = shipped[fn](...args); + + return await answered; + } finally { + globalThis.fetch = realFetch; + } + }, + + serve: async (port) => { + const server = createServer((request, response) => { + const answer = answerForUrl(request.url ?? ""); + + if ("fail" in answer) { + request.socket.destroy(); + + return; + } + + response.writeHead(answer.status, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(answer.body)); + }); + + await new Promise((resolve) => { + server.listen(port, "127.0.0.1", resolve); + }); + + return () => { + server.close(); + }; + }, +}; diff --git a/spec/bridge/source/get-address-info-by-cep.ts b/spec/bridge/source/get-address-info-by-cep.ts new file mode 100644 index 00000000..2543a11e --- /dev/null +++ b/spec/bridge/source/get-address-info-by-cep.ts @@ -0,0 +1,318 @@ +/** + * Address lookup by CEP, written once. + */ +import { + type HttpResponse, + anyFailedWith, + asString, + firstSuccess, + httpGet, + isList, + isNumber, + jsonIsTrue, + jsonInt, + jsonString, + jsonTruthy, + listHas, + startAll, +} from "./_std.ts"; + +/** Base class of every error `getAddressInfoByCep` rejects with. */ +export class GetAddressInfoByCepError extends Error {} + +/** Thrown by `getAddressInfoByCep` when the value given is not a valid CEP. */ +export class GetAddressInfoByCepValidationError extends GetAddressInfoByCepError {} + +/** Thrown by `getAddressInfoByCep` when no CEP service knows the CEP. */ +export class GetAddressInfoByCepNotFoundError extends GetAddressInfoByCepError {} + +/** Thrown by `getAddressInfoByCep` when every CEP service failed to answer. */ +export class GetAddressInfoByCepServiceError extends GetAddressInfoByCepError {} + +/** + * Raised inside a provider that did not answer. Only whether a failure was a not found is + * looked at when the provider failures are aggregated, so this one never leaves the module. + */ +class CepProviderFailure extends Error {} + +/** The address `getAddressInfoByCep` returns for a CEP. */ +export type AddressInfo = { + /** The 8 digit CEP, no mask. */ + cep: string; + /** Two letter state code, e.g. "SP". */ + state: string; + /** City name. */ + city: string; + /** Neighborhood name, empty when the CEP covers a whole city. */ + neighborhood: string; + /** Street name, empty when the CEP covers a whole city. */ + street: string; +}; + +/** The CEP services `getAddressInfoByCep` can query. */ +export type CepProvider = "viacep" | "widenet" | "brasilapi"; + +/** Options of `getAddressInfoByCep`. */ +export type GetAddressInfoByCepOptions = { + /** + * Which CEP services to race, in the order given (default: `["viacep", "brasilapi"]`; the + * deprecated `"widenet"` provider is excluded from the default list, but can still be + * requested explicitly). + */ + providers?: CepProvider[]; +}; + +const CEP_FORMAT = /^\d{8}$/; +const NON_DIGIT = /\D/g; + +/** The providers raced when the caller names none. */ +const DEFAULT_PROVIDERS: CepProvider[] = ["viacep", "brasilapi"]; + +/** Every provider that can be named, including the deprecated one. */ +const KNOWN_PROVIDERS: CepProvider[] = ["viacep", "widenet", "brasilapi"]; + +/** + * The status BrasilAPI answers an unknown CEP with, alongside an `errors` body. ViaCEP and + * Widenet report a miss inside a 200 body instead, so BrasilAPI is the only provider whose + * not-found signal is an HTTP status. + * + * @see Based on: https://brasilapi.com.br/docs#tag/CEP + */ +const BRASIL_API_NOT_FOUND_STATUS = 404; + +/** How many times a request is retried, and the base delay between attempts. */ +const HTTP_RETRIES = 2; +const HTTP_RETRY_DELAY_MS = 250; + +/** + * Reads the address ViaCEP answers with. + * + * @param {string} cep - The 8 digit CEP. + * @returns {AddressInfo} The address. + * + * @see Based on: https://viacep.com.br/ + */ +const fetchViaCep = (cep: string): AddressInfo => { + const response: HttpResponse = httpGet( + `https://viacep.com.br/ws/${cep}/json/`, + HTTP_RETRIES, + HTTP_RETRY_DELAY_MS, + ); + + if (!response.ok) { + throw new CepProviderFailure("ViaCEP request failed"); + } + + const found = jsonString(response.body, "cep"); + + if (jsonTruthy(response.body, "erro") || found === "") { + throw new GetAddressInfoByCepNotFoundError("CEP não encontrado"); + } + + return { + cep: found.replaceAll(NON_DIGIT, ""), + state: jsonString(response.body, "uf"), + city: jsonString(response.body, "localidade"), + neighborhood: jsonString(response.body, "bairro"), + street: jsonString(response.body, "logradouro"), + }; +}; + +/** + * Reads the address Widenet answers with. + * + * @param {string} cep - The 8 digit CEP. + * @returns {AddressInfo} The address. + */ +const fetchWidenet = (cep: string): AddressInfo => { + const response: HttpResponse = httpGet( + `https://apps.widenet.com.br/busca-cep/api/cep/${cep}.json`, + HTTP_RETRIES, + HTTP_RETRY_DELAY_MS, + ); + + if (!response.ok) { + throw new CepProviderFailure("Widenet request failed"); + } + + const found = jsonString(response.body, "code"); + + if ( + jsonInt(response.body, "status") !== 200 || + !jsonIsTrue(response.body, "ok") || + found === "" + ) { + throw new GetAddressInfoByCepNotFoundError("CEP não encontrado"); + } + + return { + cep: found.replaceAll(NON_DIGIT, ""), + state: jsonString(response.body, "state"), + city: jsonString(response.body, "city"), + neighborhood: jsonString(response.body, "district"), + street: jsonString(response.body, "address"), + }; +}; + +/** + * Reads the address BrasilAPI answers with. + * + * @param {string} cep - The 8 digit CEP. + * @returns {AddressInfo} The address. + * + * @see Based on: https://brasilapi.com.br/docs#tag/CEP + */ +const fetchBrasilApi = (cep: string): AddressInfo => { + const response: HttpResponse = httpGet( + `https://brasilapi.com.br/api/cep/v1/${cep}`, + HTTP_RETRIES, + HTTP_RETRY_DELAY_MS, + ); + + if (response.status === BRASIL_API_NOT_FOUND_STATUS) { + throw new GetAddressInfoByCepNotFoundError("CEP não encontrado"); + } + + if (!response.ok) { + throw new CepProviderFailure("BrasilAPI request failed"); + } + + const found = jsonString(response.body, "cep"); + + if (jsonTruthy(response.body, "errors") || found === "") { + throw new GetAddressInfoByCepNotFoundError("CEP não encontrado"); + } + + return { + cep: found.replaceAll(NON_DIGIT, ""), + state: jsonString(response.body, "state"), + city: jsonString(response.body, "city"), + neighborhood: jsonString(response.body, "neighborhood"), + street: jsonString(response.body, "street"), + }; +}; + +/** + * Asks one named provider for a CEP. + * + * @param {CepProvider} provider - The provider name. + * @param {string} cep - The 8 digit CEP. + * @returns {AddressInfo} The address. + */ +const fetchProvider = (provider: CepProvider, cep: string): AddressInfo => { + if (provider === "viacep") { + return fetchViaCep(cep); + } + + if (provider === "widenet") { + return fetchWidenet(cep); + } + + return fetchBrasilApi(cep); +}; + +/** + * The providers of a list that are known, in the order they were given. + * + * @param {CepProvider[]} given - The names the caller asked for. + * @returns {CepProvider[]} The known ones. + */ +const knownProviders = (given: CepProvider[]): CepProvider[] => { + const kept: CepProvider[] = []; + + for (const provider of given) { + if (listHas(KNOWN_PROVIDERS, provider)) { + kept.push(provider); + } + } + + return kept; +}; + +/** + * Fetches address information for a given CEP using multiple providers simultaneously. + * Returns the result from the first provider that responds successfully. + * + * The providers are started together and raced, not tried one after the other, so a provider + * that is retrying delays nothing for the others: its retries only push back the moment its own + * failure lands, and therefore the moment an all-failed rejection can surface. + * + * @param {string|number} cep - The CEP (Brazilian postal code) to search for. Can be a string or number. + * @param {GetAddressInfoByCepOptions} options - Optional configuration for the function. + * @param {CepProvider[]} options.providers - List of providers to use. Defaults to `["viacep", "brasilapi"]` + * if not specified (the deprecated `"widenet"` provider is excluded from the default list, but can still + * be requested explicitly). + * @returns {Promise} A promise that resolves to the address information. + * @throws {GetAddressInfoByCepValidationError} If the CEP format is invalid, or if + * `options.providers` is given and names no known provider: an empty array, an array of unknown + * names, and a value that is not an array at all (`null` included) all reject this way rather + * than with a raw `TypeError`. + * @throws {GetAddressInfoByCepNotFoundError} If the CEP is not found in any of the services. + * @throws {GetAddressInfoByCepServiceError} If all services are unavailable. + * + * @example + * ```typescript + * // Using the default providers (["viacep", "brasilapi"]) + * const address = await getAddressInfoByCep("01310100"); + * + * // Using specific providers + * const address = await getAddressInfoByCep("01310-100", { + * providers: ["viacep", "brasilapi"] + * }); + * + * // Using number input + * const address = await getAddressInfoByCep(1310100); + * ``` + * + * @see Official: https://www.correios.com.br/enviar/precisa-de-ajuda/tudo-sobre-cep + * @see Based on: https://viacep.com.br/ + * ViaCEP, one of the two default providers. A third-party service, not a Correios one. + * @see Based on: https://brasilapi.com.br/docs#tag/CEP + * BrasilAPI, the other default provider. A third-party service, not a Correios one. + */ +export const getAddressInfoByCep = ( + cep: string | number, + options?: GetAddressInfoByCepOptions, +): AddressInfo => { + let digits = asString(cep).replaceAll(NON_DIGIT, ""); + + if (isNumber(cep)) { + // `padStart` is a no-op when `digits` is already 8 characters or longer. + digits = digits.padStart(8, "0"); + } + + if (!CEP_FORMAT.test(digits)) { + throw new GetAddressInfoByCepValidationError("CEP inválido"); + } + + let chosen: CepProvider[] = DEFAULT_PROVIDERS; + + if (options?.providers !== undefined) { + // A value that is not a list at all reports the same thing as a list of unknown names, + // rather than the raw `TypeError` a filter over it would raise. + if (!isList(options?.providers)) { + throw new GetAddressInfoByCepValidationError("Nenhum provedor válido especificado"); + } + + chosen = knownProviders(options?.providers); + + if (chosen.length === 0) { + throw new GetAddressInfoByCepValidationError("Nenhum provedor válido especificado"); + } + } + + const attempts = startAll(fetchProvider, chosen, digits); + const found = firstSuccess(attempts); + + if (found !== undefined) { + return found; + } + + if (anyFailedWith(attempts, GetAddressInfoByCepNotFoundError)) { + throw new GetAddressInfoByCepNotFoundError("CEP não encontrado em nenhum serviço"); + } + + throw new GetAddressInfoByCepServiceError( + "Todos os serviços estão fora de serviço ou indisponíveis", + ); +};