@riligar/contract 1.0.0
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/package.json +28 -0
- package/src/codes.js +212 -0
- package/src/index.js +13 -0
- package/src/respostas.js +135 -0
- package/src/schemas.js +80 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@riligar/contract",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "O contrato de API da stack RiLiGar: envelope único, catálogo de códigos de erro e os helpers que tornam impossível responder fora dele.",
|
|
7
|
+
"main": "src/index.js",
|
|
8
|
+
"files": ["src", "README.md"],
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./codes": "./src/codes.js",
|
|
12
|
+
"./schemas": "./src/schemas.js"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test tests/",
|
|
16
|
+
"release": "semantic-release"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": { "zod": "^4.0.0" },
|
|
19
|
+
"peerDependenciesMeta": { "zod": { "optional": true } },
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"zod": "^4.5.4",
|
|
22
|
+
"semantic-release": "^24.0.0",
|
|
23
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
24
|
+
"@semantic-release/git": "^10.0.1"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": { "access": "public" },
|
|
27
|
+
"repository": { "type": "git", "url": "git+https://github.com/riligar-applications/contract.git" }
|
|
28
|
+
}
|
package/src/codes.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// CÓDIGOS DE ERRO — o vocabulário fechado da stack
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// `code` é a parte do erro que o AGENTE lê. `message` é para o humano e pode
|
|
6
|
+
// mudar de redação a qualquer momento; `code` não pode — renomear um código é
|
|
7
|
+
// quebra de contrato, e por isso ele vive aqui, num enum fechado, e não solto
|
|
8
|
+
// em cada handler.
|
|
9
|
+
//
|
|
10
|
+
// Este vocabulário não foi inventado: é a consolidação dos `reason` que já
|
|
11
|
+
// existiam espalhados pelos sete produtos (`plan_limit`, `has_resources`,
|
|
12
|
+
// `taken`, `expired`, `duplicate_name`, `no_access`…). A stack já havia
|
|
13
|
+
// convergido sozinha — só não tinha escrito, e por isso cada produto grafava
|
|
14
|
+
// o mesmo conceito de um jeito.
|
|
15
|
+
//
|
|
16
|
+
// Adicionar um código é mudança deliberada: entra aqui, não no handler.
|
|
17
|
+
|
|
18
|
+
export const CODES = {
|
|
19
|
+
// ── Identidade e acesso ──────────────────────────────────────────────
|
|
20
|
+
/** Sem credencial, ou credencial ilegível. O cliente não se identificou. */
|
|
21
|
+
UNAUTHORIZED: 'UNAUTHORIZED',
|
|
22
|
+
/** Identificado, mas sem permissão para ESTE recurso. */
|
|
23
|
+
FORBIDDEN: 'FORBIDDEN',
|
|
24
|
+
/** Token válido, emitido para OUTRO servidor (RFC 8707). */
|
|
25
|
+
WRONG_AUDIENCE: 'WRONG_AUDIENCE',
|
|
26
|
+
/** A credencial não cobre a operação — falta escopo, não permissão. */
|
|
27
|
+
INSUFFICIENT_SCOPE: 'INSUFFICIENT_SCOPE',
|
|
28
|
+
|
|
29
|
+
// ── O recurso ────────────────────────────────────────────────────────
|
|
30
|
+
/** Não existe, ou não é visível para quem pergunta. */
|
|
31
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
32
|
+
/** Já existe outro com a mesma chave natural (nome, slug, e-mail). */
|
|
33
|
+
CONFLICT: 'CONFLICT',
|
|
34
|
+
/** O nome pedido está tomado. É um CONFLICT com diagnóstico próprio. */
|
|
35
|
+
NAME_TAKEN: 'NAME_TAKEN',
|
|
36
|
+
/** Existiu e não vale mais — versão de deploy, token, link de convite. */
|
|
37
|
+
EXPIRED: 'EXPIRED',
|
|
38
|
+
/** A remoção esbarra em dependentes que precisam sair antes. */
|
|
39
|
+
HAS_RESOURCES: 'HAS_RESOURCES',
|
|
40
|
+
|
|
41
|
+
// ── O pedido ─────────────────────────────────────────────────────────
|
|
42
|
+
/** Corpo, query ou path malformado. `details` carrega o que falhou. */
|
|
43
|
+
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
|
44
|
+
/** Sintaticamente válido, mas impossível no estado atual do recurso. */
|
|
45
|
+
UNPROCESSABLE: 'UNPROCESSABLE',
|
|
46
|
+
/** Excedeu a janela de chamadas. `details.retryAfter` diz quando voltar. */
|
|
47
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
48
|
+
/** Corpo maior que o teto aceito. */
|
|
49
|
+
PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
|
|
50
|
+
|
|
51
|
+
// ── Comercial ────────────────────────────────────────────────────────
|
|
52
|
+
/** O plano atual não comporta. `details` traz current/limit/requiredPlan. */
|
|
53
|
+
PLAN_LIMIT: 'PLAN_LIMIT',
|
|
54
|
+
/** A assinatura do dono não está ativa. Bloqueia a operação inteira. */
|
|
55
|
+
SUBSCRIPTION_REQUIRED: 'SUBSCRIPTION_REQUIRED',
|
|
56
|
+
|
|
57
|
+
// ── Nós ──────────────────────────────────────────────────────────────
|
|
58
|
+
/** Falha inesperada. NUNCA carrega mensagem de exceção interna. */
|
|
59
|
+
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
|
60
|
+
/** Dependência externa fora do ar. É transitório — vale repetir. */
|
|
61
|
+
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
|
|
62
|
+
/** Um provedor de terceiros recusou (gateway, SES, Cloudflare). */
|
|
63
|
+
UPSTREAM_ERROR: 'UPSTREAM_ERROR',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* O status HTTP de cada código.
|
|
68
|
+
*
|
|
69
|
+
* A tabela vive aqui para que status e código NUNCA discordem: um `NOT_FOUND`
|
|
70
|
+
* com 200 é o defeito que esta migração existe para eliminar, e a única forma
|
|
71
|
+
* de garantir isso é o chamador não escolher o número.
|
|
72
|
+
*/
|
|
73
|
+
export const STATUS_BY_CODE = {
|
|
74
|
+
UNAUTHORIZED: 401,
|
|
75
|
+
FORBIDDEN: 403,
|
|
76
|
+
WRONG_AUDIENCE: 401,
|
|
77
|
+
INSUFFICIENT_SCOPE: 403,
|
|
78
|
+
|
|
79
|
+
NOT_FOUND: 404,
|
|
80
|
+
CONFLICT: 409,
|
|
81
|
+
NAME_TAKEN: 409,
|
|
82
|
+
EXPIRED: 410,
|
|
83
|
+
HAS_RESOURCES: 409,
|
|
84
|
+
|
|
85
|
+
VALIDATION_ERROR: 400,
|
|
86
|
+
UNPROCESSABLE: 422,
|
|
87
|
+
RATE_LIMITED: 429,
|
|
88
|
+
PAYLOAD_TOO_LARGE: 413,
|
|
89
|
+
|
|
90
|
+
PLAN_LIMIT: 402,
|
|
91
|
+
SUBSCRIPTION_REQUIRED: 402,
|
|
92
|
+
|
|
93
|
+
INTERNAL_ERROR: 500,
|
|
94
|
+
SERVICE_UNAVAILABLE: 503,
|
|
95
|
+
UPSTREAM_ERROR: 502,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Um erro transitório merece nova tentativa; os outros, não. */
|
|
99
|
+
export const RETRIABLE = new Set([CODES.RATE_LIMITED, CODES.SERVICE_UNAVAILABLE, CODES.UPSTREAM_ERROR])
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Os `reason` antigos, e o código que cada um virou.
|
|
103
|
+
*
|
|
104
|
+
* POR QUE ISTO EXISTE, E POR QUE NÃO É DÍVIDA
|
|
105
|
+
*
|
|
106
|
+
* Antes deste pacote, cada produto tinha seu vocabulário de `reason` em
|
|
107
|
+
* snake_case: `invalid_request`, `plan_limit`, `not_found`. São centenas de
|
|
108
|
+
* chamadas espalhadas — só no Auth são 54. Trocá-las todas de uma vez seria
|
|
109
|
+
* uma refatoração ampla, com risco real e zero ganho visível: o que o cliente
|
|
110
|
+
* da API lê é o `code` da RESPOSTA, e ele já sai correto.
|
|
111
|
+
*
|
|
112
|
+
* A tradução acontece na borda, uma vez, aqui. `falha('invalid_request')`
|
|
113
|
+
* emite `VALIDATION_ERROR` — o handler continua legível na língua que já
|
|
114
|
+
* falava, e a resposta cumpre o contrato.
|
|
115
|
+
*
|
|
116
|
+
* O QUE ISTO NÃO É
|
|
117
|
+
*
|
|
118
|
+
* Não é um segundo vocabulário. Nada aqui é código válido de saída: o mapa só
|
|
119
|
+
* traduz PARA `CODES`, nunca a partir dele. Código novo escreve
|
|
120
|
+
* `CODES.VALIDATION_ERROR` direto; esta tabela só cobre o que já existia.
|
|
121
|
+
*
|
|
122
|
+
* O mapa é fechado de propósito. Um `reason` que não estiver aqui cai em
|
|
123
|
+
* `INTERNAL_ERROR` — barulhento, e é o certo: código desconhecido é bug de
|
|
124
|
+
* quem chamou, não algo para adivinhar.
|
|
125
|
+
*/
|
|
126
|
+
export const ALIASES = {
|
|
127
|
+
// ── Validação ────────────────────────────────────────────────────────
|
|
128
|
+
validation_error: CODES.VALIDATION_ERROR,
|
|
129
|
+
invalid_request: CODES.VALIDATION_ERROR,
|
|
130
|
+
invalid_email: CODES.VALIDATION_ERROR,
|
|
131
|
+
bad_request: CODES.VALIDATION_ERROR,
|
|
132
|
+
missing_field: CODES.VALIDATION_ERROR,
|
|
133
|
+
|
|
134
|
+
// ── Identidade e acesso ──────────────────────────────────────────────
|
|
135
|
+
unauthorized: CODES.UNAUTHORIZED,
|
|
136
|
+
forbidden: CODES.FORBIDDEN,
|
|
137
|
+
access_denied: CODES.FORBIDDEN,
|
|
138
|
+
no_access: CODES.FORBIDDEN,
|
|
139
|
+
application_mismatch: CODES.FORBIDDEN,
|
|
140
|
+
wrong_audience: CODES.WRONG_AUDIENCE,
|
|
141
|
+
insufficient_scope: CODES.INSUFFICIENT_SCOPE,
|
|
142
|
+
|
|
143
|
+
// ── Recurso ──────────────────────────────────────────────────────────
|
|
144
|
+
not_found: CODES.NOT_FOUND,
|
|
145
|
+
conflict: CODES.CONFLICT,
|
|
146
|
+
taken: CODES.NAME_TAKEN,
|
|
147
|
+
name_taken: CODES.NAME_TAKEN,
|
|
148
|
+
duplicate_name: CODES.NAME_TAKEN,
|
|
149
|
+
expired: CODES.EXPIRED,
|
|
150
|
+
expired_token: CODES.EXPIRED,
|
|
151
|
+
has_resources: CODES.HAS_RESOURCES,
|
|
152
|
+
organization_not_empty: CODES.HAS_RESOURCES,
|
|
153
|
+
|
|
154
|
+
// ── Não processável ──────────────────────────────────────────────────
|
|
155
|
+
unprocessable: CODES.UNPROCESSABLE,
|
|
156
|
+
no_op: CODES.UNPROCESSABLE,
|
|
157
|
+
last_owner: CODES.UNPROCESSABLE,
|
|
158
|
+
// RFC 8628: o device flow espera o humano digitar o código. Não é erro de
|
|
159
|
+
// cliente nem falha do servidor — é "ainda não".
|
|
160
|
+
authorization_pending: CODES.UNPROCESSABLE,
|
|
161
|
+
|
|
162
|
+
// ── Plano e cobrança ─────────────────────────────────────────────────
|
|
163
|
+
plan_limit: CODES.PLAN_LIMIT,
|
|
164
|
+
subscription_required: CODES.SUBSCRIPTION_REQUIRED,
|
|
165
|
+
|
|
166
|
+
// ── Ritmo e tamanho ──────────────────────────────────────────────────
|
|
167
|
+
rate_limited: CODES.RATE_LIMITED,
|
|
168
|
+
slow_down: CODES.RATE_LIMITED,
|
|
169
|
+
payload_too_large: CODES.PAYLOAD_TOO_LARGE,
|
|
170
|
+
|
|
171
|
+
// ── Falha nossa ou de terceiro ───────────────────────────────────────
|
|
172
|
+
server_error: CODES.INTERNAL_ERROR,
|
|
173
|
+
internal_error: CODES.INTERNAL_ERROR,
|
|
174
|
+
service_unavailable: CODES.SERVICE_UNAVAILABLE,
|
|
175
|
+
upstream_error: CODES.UPSTREAM_ERROR,
|
|
176
|
+
email_delivery_failed: CODES.UPSTREAM_ERROR,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const isCode = valor => Object.prototype.hasOwnProperty.call(STATUS_BY_CODE, valor)
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* O código canônico a partir do que o handler escreveu.
|
|
183
|
+
*
|
|
184
|
+
* Aceita o código já canônico (passa direto), um `reason` antigo (traduz), ou
|
|
185
|
+
* qualquer outra coisa (`INTERNAL_ERROR`). É o único ponto da stack que decide
|
|
186
|
+
* isso — antes, cada produto tinha a sua tabela, e elas divergiam.
|
|
187
|
+
*/
|
|
188
|
+
export const toCode = valor => (isCode(valor) ? valor : ALIASES[valor] || CODES.INTERNAL_ERROR)
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* O código a partir de um status HTTP.
|
|
192
|
+
*
|
|
193
|
+
* Para os pontos que já tinham o número na mão — um `throw { status: 401 }`
|
|
194
|
+
* pego num catch, por exemplo — e não têm de onde tirar o código.
|
|
195
|
+
*/
|
|
196
|
+
export const STATUS_TO_CODE = {
|
|
197
|
+
400: CODES.VALIDATION_ERROR,
|
|
198
|
+
401: CODES.UNAUTHORIZED,
|
|
199
|
+
402: CODES.PLAN_LIMIT,
|
|
200
|
+
403: CODES.FORBIDDEN,
|
|
201
|
+
404: CODES.NOT_FOUND,
|
|
202
|
+
409: CODES.CONFLICT,
|
|
203
|
+
410: CODES.EXPIRED,
|
|
204
|
+
413: CODES.PAYLOAD_TOO_LARGE,
|
|
205
|
+
422: CODES.UNPROCESSABLE,
|
|
206
|
+
429: CODES.RATE_LIMITED,
|
|
207
|
+
500: CODES.INTERNAL_ERROR,
|
|
208
|
+
502: CODES.UPSTREAM_ERROR,
|
|
209
|
+
503: CODES.SERVICE_UNAVAILABLE,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const codeFromStatus = status => STATUS_TO_CODE[status] || CODES.INTERNAL_ERROR
|
package/src/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @riligar/contract — o contrato de API dos sete produtos
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// A norma escrita vive em `website/docs/2026-09-09-api-contract.md`. Este
|
|
6
|
+
// pacote é a norma EXECUTÁVEL: o que os produtos importam para responder, e o
|
|
7
|
+
// que os testes importam para conferir.
|
|
8
|
+
//
|
|
9
|
+
// A regra de ouro é a que este pacote torna mecânica: **um agente que aprendeu
|
|
10
|
+
// um produto da RiLiGar já sabe usar os outros seis.**
|
|
11
|
+
export { CODES, STATUS_BY_CODE, RETRIABLE, isCode, toCode, codeFromStatus, ALIASES } from './codes.js'
|
|
12
|
+
export { ok, criado, colecao, falha, falhaComStatus, corpoDeFalha, paginacao } from './respostas.js'
|
|
13
|
+
export { codeSchema, errorSchema, pageSchema, collectionSchema, resourceSchema, chavesCamelCase } from './schemas.js'
|
package/src/respostas.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// RESPOSTAS — a única forma de sair do servidor
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Quatro funções. Se um handler dos sete produtos constrói `Response` na mão,
|
|
6
|
+
// é porque saiu do contrato.
|
|
7
|
+
//
|
|
8
|
+
// O ponto do arquivo não é economizar digitação — é tornar o defeito
|
|
9
|
+
// IMPOSSÍVEL. `falha()` deriva o status do código: não existe assinatura que
|
|
10
|
+
// permita devolver `NOT_FOUND` com 200. Era exatamente esse par discordante
|
|
11
|
+
// que fazia o agente ler 200, concluir sucesso e seguir com dado inexistente.
|
|
12
|
+
import { CODES, STATUS_BY_CODE, RETRIABLE, isCode, toCode, codeFromStatus } from './codes.js'
|
|
13
|
+
|
|
14
|
+
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' }
|
|
15
|
+
|
|
16
|
+
const responder = (corpo, status, headers) => new Response(JSON.stringify(corpo), { status, headers: { ...JSON_HEADERS, ...headers } })
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Um recurso, na raiz.
|
|
20
|
+
*
|
|
21
|
+
* ok({ id: 'mon_a1', name: 'checkout' })
|
|
22
|
+
* → 200 {"id":"mon_a1","name":"checkout"}
|
|
23
|
+
*
|
|
24
|
+
* Sem `data`, sem `success`, sem `message`. O status HTTP já disse que deu
|
|
25
|
+
* certo; repetir isso no corpo é ruído que o agente precisa aprender a
|
|
26
|
+
* ignorar — por produto.
|
|
27
|
+
*/
|
|
28
|
+
export const ok = (recurso, { status = 200, headers } = {}) => responder(recurso, status, headers)
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Um recurso que acabou de NASCER.
|
|
32
|
+
*
|
|
33
|
+
* 201 é o sinal de criação, e só. Um upsert que atualizou devolve `ok()`;
|
|
34
|
+
* quem criou devolve `criado()`. É o que permite ao agente saber se a
|
|
35
|
+
* chamada dele foi a que criou — sem precisar comparar timestamps.
|
|
36
|
+
*/
|
|
37
|
+
export const criado = (recurso, { headers } = {}) => responder(recurso, 201, headers)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Uma coleção, sempre com a mesma forma.
|
|
41
|
+
*
|
|
42
|
+
* colecao([a, b], { limit: 50, offset: 0, total: 128 })
|
|
43
|
+
* → {"items":[…],"page":{"limit":50,"offset":0,"total":128,"hasMore":true}}
|
|
44
|
+
*
|
|
45
|
+
* `items` é sempre um array — vazio é uma resposta legítima e tem a MESMA
|
|
46
|
+
* forma da cheia. A poda de lista vazia (que o Payments fazia) quebrava só no
|
|
47
|
+
* primeiro cliente, porque todo ambiente de teste tem dado semeado.
|
|
48
|
+
*
|
|
49
|
+
* `hasMore` é explícito de propósito: o consumidor não deveria precisar
|
|
50
|
+
* comparar `offset + limit` com `total` para saber se continua paginando.
|
|
51
|
+
* Quando `total` não é conhecido (contar custaria uma query a mais em coisa
|
|
52
|
+
* que ninguém pagina), `hasMore` sai do tamanho da página cheia.
|
|
53
|
+
*/
|
|
54
|
+
export const colecao = (itens, pagina = {}, { status = 200, headers } = {}) => {
|
|
55
|
+
const items = Array.isArray(itens) ? itens : []
|
|
56
|
+
const limit = pagina.limit ?? items.length
|
|
57
|
+
const offset = pagina.offset ?? 0
|
|
58
|
+
const total = pagina.total ?? offset + items.length
|
|
59
|
+
const hasMore = pagina.hasMore ?? offset + items.length < total
|
|
60
|
+
|
|
61
|
+
return responder({ items, page: { limit, offset, total, hasMore } }, status, headers)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Uma falha. O status vem do CÓDIGO — quem chama não escolhe o número.
|
|
66
|
+
*
|
|
67
|
+
* falha(CODES.NOT_FOUND, 'Projeto não encontrado.')
|
|
68
|
+
* → 404 {"error":{"code":"NOT_FOUND","message":"Projeto não encontrado."}}
|
|
69
|
+
*
|
|
70
|
+
* `details` é para o que o agente pode AGIR: o limite que estourou, o campo
|
|
71
|
+
* que faltou, quantos segundos esperar. Nunca para stack trace.
|
|
72
|
+
*
|
|
73
|
+
* Um código desconhecido vira INTERNAL_ERROR em vez de 500 silencioso com
|
|
74
|
+
* corpo torto: se alguém escreveu um código fora do enum, o bug é nosso, e a
|
|
75
|
+
* resposta ao cliente continua bem formada.
|
|
76
|
+
*/
|
|
77
|
+
export const falha = (code, message, { details, headers, status } = {}) => {
|
|
78
|
+
const codigo = toCode(code)
|
|
79
|
+
const corpo = { error: { code: codigo, message: String(message ?? '') } }
|
|
80
|
+
|
|
81
|
+
if (details && Object.keys(details).length) corpo.error.details = details
|
|
82
|
+
if (RETRIABLE.has(codigo)) corpo.error.retriable = true
|
|
83
|
+
|
|
84
|
+
return responder(corpo, status ?? STATUS_BY_CODE[codigo], headers)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* O corpo do erro, sem a `Response` em volta.
|
|
89
|
+
*
|
|
90
|
+
* Para quem responde por um framework que monta a resposta sozinho — o Elysia
|
|
91
|
+
* do Storage e do Functions, onde o handler devolve objeto e o status vai por
|
|
92
|
+
* `set.status`. Devolve `{ corpo, status }` para os dois irem juntos e não
|
|
93
|
+
* haver como esquecer um.
|
|
94
|
+
*/
|
|
95
|
+
export const corpoDeFalha = (code, message, details) => {
|
|
96
|
+
const codigo = toCode(code)
|
|
97
|
+
const error = { code: codigo, message: String(message ?? '') }
|
|
98
|
+
|
|
99
|
+
if (details && Object.keys(details).length) error.details = details
|
|
100
|
+
if (RETRIABLE.has(codigo)) error.retriable = true
|
|
101
|
+
|
|
102
|
+
return { corpo: { error }, status: STATUS_BY_CODE[codigo] }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Lê `limit`/`offset` de uma URL, com teto.
|
|
107
|
+
*
|
|
108
|
+
* Um `limit` sem teto é um jeito de derrubar o serviço com uma query; um teto
|
|
109
|
+
* sem `offset` é o que tornava o registro 101 do Messages inalcançável. Os
|
|
110
|
+
* dois andam juntos.
|
|
111
|
+
*/
|
|
112
|
+
export const paginacao = (url, { limitPadrao = 50, limitMaximo = 200 } = {}) => {
|
|
113
|
+
const p = url instanceof URL ? url.searchParams : new URL(url, 'http://x').searchParams
|
|
114
|
+
const bruto = Number(p.get('limit'))
|
|
115
|
+
const limit = Number.isFinite(bruto) && bruto > 0 ? Math.min(Math.floor(bruto), limitMaximo) : limitPadrao
|
|
116
|
+
const off = Number(p.get('offset'))
|
|
117
|
+
const offset = Number.isFinite(off) && off > 0 ? Math.floor(off) : 0
|
|
118
|
+
return { limit, offset }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Uma falha de que só se sabe o status HTTP.
|
|
123
|
+
*
|
|
124
|
+
* Para o handler global de erro, onde chega um `throw { status, message }` de
|
|
125
|
+
* qualquer camada e não há código na mão. Deriva o `code` do número em vez de
|
|
126
|
+
* cair sempre em `INTERNAL_ERROR` — um 401 que vira 500 na borda esconde o
|
|
127
|
+
* problema real de quem depura.
|
|
128
|
+
*
|
|
129
|
+
* `status` é preservado: quem já tinha o número continua com ele, mesmo quando
|
|
130
|
+
* o código canônico mapeia para outro (401 de `UNAUTHORIZED`, 402 de
|
|
131
|
+
* `PLAN_LIMIT`).
|
|
132
|
+
*/
|
|
133
|
+
export const falhaComStatus = (status, message, details, headers) => falha(codeFromStatus(status), message, { details, headers, status })
|
|
134
|
+
|
|
135
|
+
export { CODES, STATUS_BY_CODE }
|
package/src/schemas.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SCHEMAS — o contrato como coisa executável
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// As mesmas regras de `respostas.js`, do lado de quem LÊ. Servem para o teste
|
|
6
|
+
// de contrato afirmar a forma sem reimplementar a asserção em cada suíte, e
|
|
7
|
+
// para um consumidor validar o que recebeu.
|
|
8
|
+
//
|
|
9
|
+
// Zod é peer: os sete produtos já o têm (v4), e embutir uma segunda cópia num
|
|
10
|
+
// worker significa pagar o bundle duas vezes.
|
|
11
|
+
import { z } from 'zod'
|
|
12
|
+
|
|
13
|
+
import { STATUS_BY_CODE } from './codes.js'
|
|
14
|
+
|
|
15
|
+
/** O código é o enum fechado — nada de string livre. */
|
|
16
|
+
export const codeSchema = z.enum(Object.keys(STATUS_BY_CODE))
|
|
17
|
+
|
|
18
|
+
export const errorSchema = z.object({
|
|
19
|
+
error: z.object({
|
|
20
|
+
code: codeSchema,
|
|
21
|
+
message: z.string().min(1),
|
|
22
|
+
details: z.record(z.string(), z.unknown()).optional(),
|
|
23
|
+
retriable: z.boolean().optional(),
|
|
24
|
+
}),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const pageSchema = z.object({
|
|
28
|
+
limit: z.number().int().nonnegative(),
|
|
29
|
+
offset: z.number().int().nonnegative(),
|
|
30
|
+
total: z.number().int().nonnegative(),
|
|
31
|
+
hasMore: z.boolean(),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/** `{items, page}` — a forma de TODA coleção. */
|
|
35
|
+
export const collectionSchema = item =>
|
|
36
|
+
z.object({
|
|
37
|
+
items: z.array(item ?? z.unknown()),
|
|
38
|
+
page: pageSchema,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* O recurso vai na raiz, então não há envelope a validar — o que se pode
|
|
43
|
+
* afirmar é o que ele NÃO pode ser.
|
|
44
|
+
*
|
|
45
|
+
* As três chaves proibidas são exatamente os envelopes que a stack usava. Um
|
|
46
|
+
* recurso que ainda tenha `data`, `success` ou `message` no topo é um produto
|
|
47
|
+
* que não migrou — e o teste de contrato precisa dizer isso com todas as
|
|
48
|
+
* letras, em vez de passar porque "veio um objeto".
|
|
49
|
+
*/
|
|
50
|
+
export const resourceSchema = corpo =>
|
|
51
|
+
(corpo ?? z.object({}).loose()).refine(v => !v || typeof v !== 'object' || (!('data' in v) && !('success' in v) && !('message' in v)), {
|
|
52
|
+
message: 'recurso não pode vir envelopado em `data`, `success` ou `message`',
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const CAMEL = /^[a-z][a-zA-Z0-9]*$/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Toda chave do corpo é camelCase, em qualquer profundidade.
|
|
59
|
+
*
|
|
60
|
+
* O Storage devolvia `tenant_id` no corpo e `tenantId` no path da MESMA rota.
|
|
61
|
+
* Um consumidor precisava saber, campo a campo, qual vocabulário usar.
|
|
62
|
+
*
|
|
63
|
+
* Chaves que são DADO do usuário (o nome de uma coleção, de uma variável de
|
|
64
|
+
* ambiente, de um cabeçalho) não seguem a regra — não são contrato nosso.
|
|
65
|
+
* Por isso `ignorar`.
|
|
66
|
+
*/
|
|
67
|
+
export const chavesCamelCase = (valor, { ignorar = [] } = {}, caminho = '') => {
|
|
68
|
+
const fora = []
|
|
69
|
+
const anda = (v, path) => {
|
|
70
|
+
if (Array.isArray(v)) return v.forEach((x, i) => anda(x, `${path}[${i}]`))
|
|
71
|
+
if (!v || typeof v !== 'object') return
|
|
72
|
+
for (const [k, sub] of Object.entries(v)) {
|
|
73
|
+
const aqui = path ? `${path}.${k}` : k
|
|
74
|
+
if (!ignorar.includes(k) && !CAMEL.test(k)) fora.push(aqui)
|
|
75
|
+
anda(sub, aqui)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
anda(valor, caminho)
|
|
79
|
+
return fora
|
|
80
|
+
}
|