@radardata/sdk 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +202 -2
- package/dist/cjs/configuracao.js +28 -0
- package/dist/cjs/configuracao.js.map +1 -0
- package/dist/cjs/dados.js +44 -0
- package/dist/cjs/dados.js.map +1 -0
- package/dist/cjs/erros.js +86 -0
- package/dist/cjs/erros.js.map +1 -0
- package/dist/cjs/index.js +69 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/recursos/analises.js +53 -0
- package/dist/cjs/recursos/analises.js.map +1 -0
- package/dist/cjs/recursos/catalogo.js +21 -0
- package/dist/cjs/recursos/catalogo.js.map +1 -0
- package/dist/cjs/recursos/consultas.js +33 -0
- package/dist/cjs/recursos/consultas.js.map +1 -0
- package/dist/cjs/recursos/verify.js +39 -0
- package/dist/cjs/recursos/verify.js.map +1 -0
- package/dist/cjs/recursos/webhooks.js +45 -0
- package/dist/cjs/recursos/webhooks.js.map +1 -0
- package/dist/cjs/transporte.js +128 -0
- package/dist/cjs/transporte.js.map +1 -0
- package/dist/cjs/webhooks/assinatura.js +65 -0
- package/dist/cjs/webhooks/assinatura.js.map +1 -0
- package/dist/esm/configuracao.d.ts +27 -0
- package/dist/esm/configuracao.d.ts.map +1 -0
- package/dist/esm/configuracao.js +23 -0
- package/dist/esm/configuracao.js.map +1 -0
- package/dist/esm/dados.d.ts +59 -0
- package/dist/esm/dados.d.ts.map +1 -0
- package/dist/esm/dados.js +38 -0
- package/dist/esm/dados.js.map +1 -0
- package/dist/esm/erros.d.ts +53 -0
- package/dist/esm/erros.d.ts.map +1 -0
- package/dist/esm/erros.js +73 -0
- package/dist/esm/erros.js.map +1 -0
- package/dist/esm/index.d.ts +41 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +47 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/recursos/analises.d.ts +25 -0
- package/dist/esm/recursos/analises.d.ts.map +1 -0
- package/dist/esm/recursos/analises.js +49 -0
- package/dist/esm/recursos/analises.js.map +1 -0
- package/dist/esm/recursos/catalogo.d.ts +11 -0
- package/dist/esm/recursos/catalogo.d.ts.map +1 -0
- package/dist/esm/recursos/catalogo.js +17 -0
- package/dist/esm/recursos/catalogo.js.map +1 -0
- package/dist/esm/recursos/consultas.d.ts +22 -0
- package/dist/esm/recursos/consultas.d.ts.map +1 -0
- package/dist/esm/recursos/consultas.js +29 -0
- package/dist/esm/recursos/consultas.js.map +1 -0
- package/dist/esm/recursos/verify.d.ts +25 -0
- package/dist/esm/recursos/verify.d.ts.map +1 -0
- package/dist/esm/recursos/verify.js +35 -0
- package/dist/esm/recursos/verify.js.map +1 -0
- package/dist/esm/recursos/webhooks.d.ts +27 -0
- package/dist/esm/recursos/webhooks.d.ts.map +1 -0
- package/dist/esm/recursos/webhooks.js +41 -0
- package/dist/esm/recursos/webhooks.js.map +1 -0
- package/dist/esm/transporte.d.ts +35 -0
- package/dist/esm/transporte.d.ts.map +1 -0
- package/dist/esm/transporte.js +123 -0
- package/dist/esm/transporte.js.map +1 -0
- package/dist/esm/webhooks/assinatura.d.ts +32 -0
- package/dist/esm/webhooks/assinatura.d.ts.map +1 -0
- package/dist/esm/webhooks/assinatura.js +61 -0
- package/dist/esm/webhooks/assinatura.js.map +1 -0
- package/package.json +47 -4
- package/src/configuracao.ts +50 -0
- package/src/dados.ts +93 -0
- package/src/erros.ts +70 -0
- package/src/index.ts +66 -0
- package/src/recursos/analises.ts +61 -0
- package/src/recursos/catalogo.ts +17 -0
- package/src/recursos/consultas.ts +32 -0
- package/src/recursos/verify.ts +40 -0
- package/src/recursos/webhooks.ts +43 -0
- package/src/transporte.ts +145 -0
- package/src/webhooks/assinatura.ts +72 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { type ConfigResolvida, userAgent } from "./configuracao.js";
|
|
2
|
+
import { ErroRadarData, FalhaDeRede, erroDoStatus } from "./erros.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* O transporte: fala HTTP com a `/api/v2` e traduz resposta em dado ou erro.
|
|
6
|
+
*
|
|
7
|
+
* Tres decisoes que valem o comentario, porque sao as que diferenciam um SDK
|
|
8
|
+
* de um `fetch` embrulhado:
|
|
9
|
+
*
|
|
10
|
+
* 1. RETRY SO ONDE E SEGURO. A api aceita `Idempotency-Key` em consulta,
|
|
11
|
+
* analise e operacao, e e so nessas que repetir e seguro: a mesma chave
|
|
12
|
+
* devolve a mesma resposta em vez de cobrar de novo. Criar sessao de
|
|
13
|
+
* Verify NAO tem idempotencia, entao repetir ali criaria uma segunda
|
|
14
|
+
* sessao faturavel. Por isso o retry nao e uma opcao global: ele e ligado
|
|
15
|
+
* por chamada, e so quando o SDK manda a chave.
|
|
16
|
+
* 2. A CHAVE E GERADA PELO SDK quando quem chama nao passa uma. Deixar isso
|
|
17
|
+
* para o integrador significa, na pratica, ninguem usar, e e justamente o
|
|
18
|
+
* caso em que a falha doi: timeout numa consulta paga.
|
|
19
|
+
* 3. FALHA SEM RESPOSTA NAO E ERRO DE SERVIDOR. `FalhaDeRede` existe porque
|
|
20
|
+
* nesse caso nao se sabe se a operacao aconteceu, e a acao correta e
|
|
21
|
+
* consultar pela chave, nao repetir as cegas.
|
|
22
|
+
*/
|
|
23
|
+
export class Transporte {
|
|
24
|
+
constructor(private readonly config: ConfigResolvida) {}
|
|
25
|
+
|
|
26
|
+
async pedir<T = Record<string, unknown>>(
|
|
27
|
+
metodo: string,
|
|
28
|
+
caminho: string,
|
|
29
|
+
opcoes: {
|
|
30
|
+
corpo?: Record<string, unknown> | null;
|
|
31
|
+
query?: Record<string, string | number | undefined>;
|
|
32
|
+
chaveIdempotencia?: string;
|
|
33
|
+
} = {},
|
|
34
|
+
): Promise<T> {
|
|
35
|
+
const { corpo, query, chaveIdempotencia } = opcoes;
|
|
36
|
+
|
|
37
|
+
const cabecalhos: Record<string, string> = {
|
|
38
|
+
Authorization: `Bearer ${this.config.token}`,
|
|
39
|
+
Accept: "application/json",
|
|
40
|
+
"User-Agent": userAgent(),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Repetir so e seguro com a chave: sem ela, uma consulta cobrada duas
|
|
44
|
+
// vezes seria dinheiro do cliente.
|
|
45
|
+
const podeRepetir = typeof chaveIdempotencia === "string" && chaveIdempotencia !== "";
|
|
46
|
+
if (podeRepetir) cabecalhos["Idempotency-Key"] = chaveIdempotencia;
|
|
47
|
+
if (corpo != null) cabecalhos["Content-Type"] = "application/json";
|
|
48
|
+
|
|
49
|
+
let url = `${this.config.base}/${caminho.replace(/^\/+/, "")}`;
|
|
50
|
+
if (query) {
|
|
51
|
+
const q = new URLSearchParams();
|
|
52
|
+
for (const [k, v] of Object.entries(query)) if (v !== undefined) q.set(k, String(v));
|
|
53
|
+
if ([...q].length > 0) url += `?${q.toString()}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const tentativas = podeRepetir ? this.config.tentativas : 1;
|
|
57
|
+
let ultimo: ErroRadarData | null = null;
|
|
58
|
+
|
|
59
|
+
for (let i = 1; i <= tentativas; i++) {
|
|
60
|
+
const abortar = new AbortController();
|
|
61
|
+
const relogio = setTimeout(() => abortar.abort(), this.config.timeoutMs);
|
|
62
|
+
|
|
63
|
+
let resposta: Response;
|
|
64
|
+
try {
|
|
65
|
+
resposta = await this.config.fetch(url, {
|
|
66
|
+
method: metodo,
|
|
67
|
+
headers: cabecalhos,
|
|
68
|
+
body: corpo != null ? JSON.stringify(corpo) : undefined,
|
|
69
|
+
signal: abortar.signal,
|
|
70
|
+
});
|
|
71
|
+
} catch (e) {
|
|
72
|
+
ultimo = new FalhaDeRede(
|
|
73
|
+
`Não foi possível falar com o RadarData: ${e instanceof Error ? e.message : String(e)}`,
|
|
74
|
+
"falha_de_rede",
|
|
75
|
+
null,
|
|
76
|
+
{},
|
|
77
|
+
{ cause: e },
|
|
78
|
+
);
|
|
79
|
+
if (i < tentativas) {
|
|
80
|
+
await this.esperar(i);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
throw ultimo;
|
|
84
|
+
} finally {
|
|
85
|
+
clearTimeout(relogio);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 429 e 5xx valem nova tentativa; o resto e resposta final.
|
|
89
|
+
if ((resposta.status === 429 || resposta.status >= 500) && i < tentativas) {
|
|
90
|
+
await this.esperar(i, resposta);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (await this.interpretar(resposta)) as T;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
throw ultimo ?? new ErroRadarData("Falha desconhecida no transporte.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async interpretar(resposta: Response): Promise<unknown> {
|
|
101
|
+
const cru = await resposta.text();
|
|
102
|
+
let json: unknown;
|
|
103
|
+
try {
|
|
104
|
+
json = cru === "" ? {} : JSON.parse(cru);
|
|
105
|
+
} catch {
|
|
106
|
+
json = {};
|
|
107
|
+
}
|
|
108
|
+
const corpo = (json ?? {}) as Record<string, unknown>;
|
|
109
|
+
|
|
110
|
+
if (resposta.ok) {
|
|
111
|
+
// `data` quando a rota embrulha (catalogo, saldo, analises) e o corpo
|
|
112
|
+
// inteiro quando nao (sessao de Verify). Devolver sempre o conteudo
|
|
113
|
+
// util poupa quem chama de saber qual rota faz o que.
|
|
114
|
+
return "data" in corpo && corpo.data !== null && typeof corpo.data === "object" ? corpo.data : corpo;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const erro = (corpo.error ?? {}) as { code?: string; message?: string };
|
|
118
|
+
const mensagem =
|
|
119
|
+
erro.message ?? (corpo.message as string | undefined) ?? `O RadarData respondeu ${resposta.status} sem detalhe.`;
|
|
120
|
+
|
|
121
|
+
throw erroDoStatus(resposta.status, mensagem, erro.code ?? null, corpo);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Espera entre tentativas, respeitando `Retry-After` quando ele vem. */
|
|
125
|
+
private async esperar(tentativa: number, resposta?: Response): Promise<void> {
|
|
126
|
+
const cabecalho = Number(resposta?.headers.get("Retry-After") ?? 0);
|
|
127
|
+
|
|
128
|
+
if (cabecalho > 0) {
|
|
129
|
+
await new Promise((r) => setTimeout(r, Math.min(cabecalho, 30) * 1000));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Recuo exponencial com um pouco de aleatorio, para muitos clientes
|
|
134
|
+
// falhando juntos nao voltarem todos no mesmo instante.
|
|
135
|
+
const base = Math.min(8, 2 ** (tentativa - 1));
|
|
136
|
+
await new Promise((r) => setTimeout(r, base * 1000 + Math.random() * 250));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Chave nova de idempotencia, no formato que a api aceita. */
|
|
141
|
+
export function chaveIdempotencia(): string {
|
|
142
|
+
const bytes = new Uint8Array(16);
|
|
143
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
144
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
145
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Confere que o webhook veio mesmo do RadarData.
|
|
5
|
+
*
|
|
6
|
+
* Mandamos `X-RadarData-Signature: sha256=<hmac do CORPO CRU com o seu
|
|
7
|
+
* webhook_secret>`, mais `X-RadarData-Event`, `X-RadarData-Delivery` e
|
|
8
|
+
* `X-RadarData-Timestamp`.
|
|
9
|
+
*
|
|
10
|
+
* Aceita `string` ou `Uint8Array`, e nao `Buffer`, de proposito: `Buffer` e um
|
|
11
|
+
* global do Node e poria `@types/node` na superficie publica do pacote, o que
|
|
12
|
+
* quebra o `tsc` de quem compila para borda, Deno ou navegador. `Buffer` E um
|
|
13
|
+
* `Uint8Array`, entao o que o `express.raw()` entrega continua servindo.
|
|
14
|
+
*
|
|
15
|
+
* O CORPO TEM DE SER O CRU. Em Express e `express.raw()`, nao `express.json()`;
|
|
16
|
+
* reserializar o objeto ja decodificado muda espaco e ordem de chave, o HMAC
|
|
17
|
+
* muda junto e a conferencia falha sem motivo aparente. E o erro mais comum
|
|
18
|
+
* nesta parte, em qualquer integracao.
|
|
19
|
+
*
|
|
20
|
+
* LIMITE QUE VOCE PRECISA CONHECER: o timestamp e o id de entrega viajam em
|
|
21
|
+
* CABECALHO, fora do corpo assinado, entao a assinatura prova a ORIGEM e o
|
|
22
|
+
* CONTEUDO, e nao a hora. Ela nao protege contra reenvio de um aviso antigo e
|
|
23
|
+
* autentico por quem o tenha interceptado. A defesa pratica e guardar o
|
|
24
|
+
* `X-RadarData-Delivery` e ignorar o que ja foi processado, que voce deve
|
|
25
|
+
* fazer de qualquer forma, porque reenviamos em caso de falha.
|
|
26
|
+
*/
|
|
27
|
+
export function confereAssinatura(
|
|
28
|
+
corpoCru: string | Uint8Array,
|
|
29
|
+
assinatura: string | null | undefined,
|
|
30
|
+
segredo: string,
|
|
31
|
+
): boolean {
|
|
32
|
+
if (!assinatura || !segredo) return false;
|
|
33
|
+
|
|
34
|
+
const esperada = `sha256=${createHmac("sha256", segredo).update(corpoCru).digest("hex")}`;
|
|
35
|
+
|
|
36
|
+
const a = Buffer.from(esperada, "utf8");
|
|
37
|
+
const b = Buffer.from(assinatura, "utf8");
|
|
38
|
+
|
|
39
|
+
// `timingSafeEqual` exige tamanhos iguais e lança quando diferem; comparar
|
|
40
|
+
// o tamanho antes não vaza nada, porque o tamanho da assinatura é fixo e
|
|
41
|
+
// público.
|
|
42
|
+
if (a.length !== b.length) return false;
|
|
43
|
+
|
|
44
|
+
// E não `===`: comparação normal vaza, pelo tempo que leva, quantos bytes
|
|
45
|
+
// iniciais bateram, e com isso dá para descobrir a assinatura byte a byte.
|
|
46
|
+
return timingSafeEqual(a, b);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Confere e devolve o evento decodificado, ou lança.
|
|
51
|
+
*
|
|
52
|
+
* @throws Error quando a assinatura nao confere
|
|
53
|
+
*/
|
|
54
|
+
export function exigirAssinatura(
|
|
55
|
+
corpoCru: string | Uint8Array,
|
|
56
|
+
assinatura: string | null | undefined,
|
|
57
|
+
segredo: string,
|
|
58
|
+
): Record<string, unknown> {
|
|
59
|
+
if (!confereAssinatura(corpoCru, assinatura, segredo)) {
|
|
60
|
+
throw new Error("Assinatura do webhook do RadarData não confere.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
// `TextDecoder` e nao `Buffer.toString`: existe em Node, em navegador, em
|
|
65
|
+
// Deno e em Bun, e nao arrasta os tipos do Node para a superficie publica.
|
|
66
|
+
const texto = typeof corpoCru === "string" ? corpoCru : new TextDecoder().decode(corpoCru);
|
|
67
|
+
const dados = JSON.parse(texto);
|
|
68
|
+
return dados && typeof dados === "object" ? (dados as Record<string, unknown>) : {};
|
|
69
|
+
} catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|