@riligar/contract 1.0.1 → 2.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 +2 -2
- package/src/codes.js +77 -74
- package/src/index.js +17 -17
- package/src/responses.js +189 -0
- package/src/schemas.js +32 -31
- package/src/respostas.js +0 -135
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@riligar/contract",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "
|
|
6
|
+
"description": "The API contract of the RiLiGar stack: a single envelope, a catalogue of error codes, and the helpers that make it impossible to answer outside it.",
|
|
7
7
|
"main": "src/index.js",
|
|
8
8
|
"files": [
|
|
9
9
|
"src",
|
package/src/codes.js
CHANGED
|
@@ -1,74 +1,74 @@
|
|
|
1
1
|
// ============================================================================
|
|
2
|
-
//
|
|
2
|
+
// ERROR CODES — the stack's closed vocabulary
|
|
3
3
|
// ============================================================================
|
|
4
4
|
//
|
|
5
|
-
// `code`
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// `code` is the part of the error the AGENT reads. `message` is for the human
|
|
6
|
+
// and its wording may change at any time; `code` may not — renaming a code is
|
|
7
|
+
// a breaking change to the contract, and that is why it lives here, in a closed
|
|
8
|
+
// enum, instead of loose in each handler.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// `taken`, `expired`, `duplicate_name`,
|
|
13
|
-
//
|
|
14
|
-
//
|
|
10
|
+
// This vocabulary was not invented: it is the consolidation of the `reason`
|
|
11
|
+
// values that already existed scattered across the seven products
|
|
12
|
+
// (`plan_limit`, `has_resources`, `taken`, `expired`, `duplicate_name`,
|
|
13
|
+
// `no_access`…). The stack had already converged on its own — it just had never
|
|
14
|
+
// written it down, and so each product spelled the same concept its own way.
|
|
15
15
|
//
|
|
16
|
-
//
|
|
16
|
+
// Adding a code is a deliberate change: it goes here, not in the handler.
|
|
17
17
|
|
|
18
18
|
export const CODES = {
|
|
19
|
-
// ──
|
|
20
|
-
/**
|
|
19
|
+
// ── Identity and access ──────────────────────────────────────────────
|
|
20
|
+
/** No credential, or an unreadable one. The client did not identify itself. */
|
|
21
21
|
UNAUTHORIZED: 'UNAUTHORIZED',
|
|
22
|
-
/**
|
|
22
|
+
/** Identified, but without permission for THIS resource. */
|
|
23
23
|
FORBIDDEN: 'FORBIDDEN',
|
|
24
|
-
/**
|
|
24
|
+
/** Valid token, issued for ANOTHER server (RFC 8707). */
|
|
25
25
|
WRONG_AUDIENCE: 'WRONG_AUDIENCE',
|
|
26
|
-
/**
|
|
26
|
+
/** The credential does not cover the operation — missing scope, not permission. */
|
|
27
27
|
INSUFFICIENT_SCOPE: 'INSUFFICIENT_SCOPE',
|
|
28
28
|
|
|
29
|
-
// ──
|
|
30
|
-
/**
|
|
29
|
+
// ── The resource ─────────────────────────────────────────────────────
|
|
30
|
+
/** Does not exist, or is not visible to whoever is asking. */
|
|
31
31
|
NOT_FOUND: 'NOT_FOUND',
|
|
32
|
-
/**
|
|
32
|
+
/** Another one already exists with the same natural key (name, slug, e-mail). */
|
|
33
33
|
CONFLICT: 'CONFLICT',
|
|
34
|
-
/**
|
|
34
|
+
/** The requested name is taken. It is a CONFLICT with its own diagnosis. */
|
|
35
35
|
NAME_TAKEN: 'NAME_TAKEN',
|
|
36
|
-
/**
|
|
36
|
+
/** It existed and no longer holds — deploy version, token, invite link. */
|
|
37
37
|
EXPIRED: 'EXPIRED',
|
|
38
|
-
/**
|
|
38
|
+
/** The removal runs into dependents that have to go first. */
|
|
39
39
|
HAS_RESOURCES: 'HAS_RESOURCES',
|
|
40
40
|
|
|
41
|
-
// ──
|
|
42
|
-
/**
|
|
41
|
+
// ── The request ──────────────────────────────────────────────────────
|
|
42
|
+
/** Malformed body, query or path. `details` carries what failed. */
|
|
43
43
|
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
|
44
|
-
/**
|
|
44
|
+
/** Syntactically valid, but impossible in the resource's current state. */
|
|
45
45
|
UNPROCESSABLE: 'UNPROCESSABLE',
|
|
46
|
-
/**
|
|
46
|
+
/** Exceeded the call window. `details.retryAfter` says when to come back. */
|
|
47
47
|
RATE_LIMITED: 'RATE_LIMITED',
|
|
48
|
-
/**
|
|
48
|
+
/** Body larger than the accepted ceiling. */
|
|
49
49
|
PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
|
|
50
50
|
|
|
51
|
-
// ──
|
|
52
|
-
/**
|
|
51
|
+
// ── Commercial ───────────────────────────────────────────────────────
|
|
52
|
+
/** The current plan does not cover it. `details` brings current/limit/requiredPlan. */
|
|
53
53
|
PLAN_LIMIT: 'PLAN_LIMIT',
|
|
54
|
-
/**
|
|
54
|
+
/** The owner's subscription is not active. Blocks the whole operation. */
|
|
55
55
|
SUBSCRIPTION_REQUIRED: 'SUBSCRIPTION_REQUIRED',
|
|
56
56
|
|
|
57
|
-
// ──
|
|
58
|
-
/**
|
|
57
|
+
// ── Us ───────────────────────────────────────────────────────────────
|
|
58
|
+
/** Unexpected failure. NEVER carries an internal exception message. */
|
|
59
59
|
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
|
60
|
-
/**
|
|
60
|
+
/** External dependency down. It is transient — worth retrying. */
|
|
61
61
|
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
|
|
62
|
-
/**
|
|
62
|
+
/** A third-party provider refused (gateway, SES, Cloudflare). */
|
|
63
63
|
UPSTREAM_ERROR: 'UPSTREAM_ERROR',
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
*
|
|
67
|
+
* The HTTP status of each code.
|
|
68
68
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
69
|
+
* The table lives here so that status and code NEVER disagree: a `NOT_FOUND`
|
|
70
|
+
* with a 200 is the defect this migration exists to eliminate, and the only way
|
|
71
|
+
* to guarantee that is for the caller not to pick the number.
|
|
72
72
|
*/
|
|
73
73
|
export const STATUS_BY_CODE = {
|
|
74
74
|
UNAUTHORIZED: 401,
|
|
@@ -95,43 +95,45 @@ export const STATUS_BY_CODE = {
|
|
|
95
95
|
UPSTREAM_ERROR: 502,
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/**
|
|
98
|
+
/** A transient error deserves a retry; the others do not. */
|
|
99
99
|
export const RETRIABLE = new Set([CODES.RATE_LIMITED, CODES.SERVICE_UNAVAILABLE, CODES.UPSTREAM_ERROR])
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
*
|
|
102
|
+
* The old `reason` values, and the code each one became.
|
|
103
103
|
*
|
|
104
|
-
*
|
|
104
|
+
* WHY THIS EXISTS, AND WHY IT IS NOT DEBT
|
|
105
105
|
*
|
|
106
|
-
*
|
|
107
|
-
* snake_case: `invalid_request`, `plan_limit`, `not_found`.
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
106
|
+
* Before this package, each product had its own vocabulary of `reason` values
|
|
107
|
+
* in snake_case: `invalid_request`, `plan_limit`, `not_found`. There are
|
|
108
|
+
* hundreds of calls scattered around — in Auth alone there are 54. Swapping
|
|
109
|
+
* them all at once would be a sweeping refactor, with real risk and zero
|
|
110
|
+
* visible gain: what the API client reads is the `code` of the RESPONSE, and
|
|
111
|
+
* that already comes out correct.
|
|
111
112
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
113
|
+
* The translation happens at the edge, once, here. `failure('invalid_request')`
|
|
114
|
+
* emits `VALIDATION_ERROR` — the handler stays readable in the language it
|
|
115
|
+
* already spoke, and the response honours the contract.
|
|
115
116
|
*
|
|
116
|
-
*
|
|
117
|
+
* WHAT THIS IS NOT
|
|
117
118
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* `CODES.VALIDATION_ERROR`
|
|
119
|
+
* It is not a second vocabulary. Nothing here is a valid output code: the map
|
|
120
|
+
* only translates INTO `CODES`, never out of it. New code writes
|
|
121
|
+
* `CODES.VALIDATION_ERROR` directly; this table only covers what already
|
|
122
|
+
* existed.
|
|
121
123
|
*
|
|
122
|
-
*
|
|
123
|
-
* `INTERNAL_ERROR` —
|
|
124
|
-
*
|
|
124
|
+
* The map is closed on purpose. A `reason` that is not here falls into
|
|
125
|
+
* `INTERNAL_ERROR` — loud, and that is the right call: an unknown code is a bug
|
|
126
|
+
* in whoever called, not something to guess at.
|
|
125
127
|
*/
|
|
126
128
|
export const ALIASES = {
|
|
127
|
-
// ──
|
|
129
|
+
// ── Validation ───────────────────────────────────────────────────────
|
|
128
130
|
validation_error: CODES.VALIDATION_ERROR,
|
|
129
131
|
invalid_request: CODES.VALIDATION_ERROR,
|
|
130
132
|
invalid_email: CODES.VALIDATION_ERROR,
|
|
131
133
|
bad_request: CODES.VALIDATION_ERROR,
|
|
132
134
|
missing_field: CODES.VALIDATION_ERROR,
|
|
133
135
|
|
|
134
|
-
// ──
|
|
136
|
+
// ── Identity and access ──────────────────────────────────────────────
|
|
135
137
|
unauthorized: CODES.UNAUTHORIZED,
|
|
136
138
|
forbidden: CODES.FORBIDDEN,
|
|
137
139
|
access_denied: CODES.FORBIDDEN,
|
|
@@ -140,7 +142,7 @@ export const ALIASES = {
|
|
|
140
142
|
wrong_audience: CODES.WRONG_AUDIENCE,
|
|
141
143
|
insufficient_scope: CODES.INSUFFICIENT_SCOPE,
|
|
142
144
|
|
|
143
|
-
// ──
|
|
145
|
+
// ── Resource ─────────────────────────────────────────────────────────
|
|
144
146
|
not_found: CODES.NOT_FOUND,
|
|
145
147
|
conflict: CODES.CONFLICT,
|
|
146
148
|
taken: CODES.NAME_TAKEN,
|
|
@@ -151,24 +153,24 @@ export const ALIASES = {
|
|
|
151
153
|
has_resources: CODES.HAS_RESOURCES,
|
|
152
154
|
organization_not_empty: CODES.HAS_RESOURCES,
|
|
153
155
|
|
|
154
|
-
// ──
|
|
156
|
+
// ── Unprocessable ────────────────────────────────────────────────────
|
|
155
157
|
unprocessable: CODES.UNPROCESSABLE,
|
|
156
158
|
no_op: CODES.UNPROCESSABLE,
|
|
157
159
|
last_owner: CODES.UNPROCESSABLE,
|
|
158
|
-
// RFC 8628:
|
|
159
|
-
//
|
|
160
|
+
// RFC 8628: the device flow waits for the human to type the code. It is
|
|
161
|
+
// neither a client error nor a server failure — it is "not yet".
|
|
160
162
|
authorization_pending: CODES.UNPROCESSABLE,
|
|
161
163
|
|
|
162
|
-
// ──
|
|
164
|
+
// ── Plan and billing ─────────────────────────────────────────────────
|
|
163
165
|
plan_limit: CODES.PLAN_LIMIT,
|
|
164
166
|
subscription_required: CODES.SUBSCRIPTION_REQUIRED,
|
|
165
167
|
|
|
166
|
-
// ──
|
|
168
|
+
// ── Pace and size ────────────────────────────────────────────────────
|
|
167
169
|
rate_limited: CODES.RATE_LIMITED,
|
|
168
170
|
slow_down: CODES.RATE_LIMITED,
|
|
169
171
|
payload_too_large: CODES.PAYLOAD_TOO_LARGE,
|
|
170
172
|
|
|
171
|
-
// ──
|
|
173
|
+
// ── Our failure, or a third party's ──────────────────────────────────
|
|
172
174
|
server_error: CODES.INTERNAL_ERROR,
|
|
173
175
|
internal_error: CODES.INTERNAL_ERROR,
|
|
174
176
|
service_unavailable: CODES.SERVICE_UNAVAILABLE,
|
|
@@ -176,22 +178,23 @@ export const ALIASES = {
|
|
|
176
178
|
email_delivery_failed: CODES.UPSTREAM_ERROR,
|
|
177
179
|
}
|
|
178
180
|
|
|
179
|
-
export const isCode =
|
|
181
|
+
export const isCode = value => Object.prototype.hasOwnProperty.call(STATUS_BY_CODE, value)
|
|
180
182
|
|
|
181
183
|
/**
|
|
182
|
-
*
|
|
184
|
+
* The canonical code, derived from whatever the handler wrote.
|
|
183
185
|
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
186
|
+
* Accepts the already canonical code (passes straight through), an old `reason`
|
|
187
|
+
* (translates it), or anything else (`INTERNAL_ERROR`). It is the only point in
|
|
188
|
+
* the stack that decides this — before, each product had its own table, and they
|
|
189
|
+
* diverged.
|
|
187
190
|
*/
|
|
188
|
-
export const toCode =
|
|
191
|
+
export const toCode = value => (isCode(value) ? value : ALIASES[value] || CODES.INTERNAL_ERROR)
|
|
189
192
|
|
|
190
193
|
/**
|
|
191
|
-
*
|
|
194
|
+
* The code, derived from an HTTP status.
|
|
192
195
|
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
196
|
+
* For the places that already had the number at hand — a `throw { status: 401 }`
|
|
197
|
+
* caught in a catch, for example — and have nowhere to get the code from.
|
|
195
198
|
*/
|
|
196
199
|
export const STATUS_TO_CODE = {
|
|
197
200
|
400: CODES.VALIDATION_ERROR,
|
package/src/index.js
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
// ============================================================================
|
|
2
|
-
// @riligar/contract —
|
|
2
|
+
// @riligar/contract — the API contract of the seven products
|
|
3
3
|
// ============================================================================
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// The written norm lives in `website/docs/2026-09-09-api-contract.md`. This
|
|
6
|
+
// package is the EXECUTABLE norm: what the products import in order to respond,
|
|
7
|
+
// and what the tests import in order to check.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// The golden rule is the one this package makes mechanical: **an agent that has
|
|
10
|
+
// learned one RiLiGar product already knows how to use the other six.**
|
|
11
11
|
//
|
|
12
|
-
//
|
|
12
|
+
// WHY THE SCHEMAS DO NOT COME OUT OF HERE
|
|
13
13
|
//
|
|
14
|
-
// `schemas.js`
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// '@riligar/contract'`
|
|
18
|
-
//
|
|
19
|
-
//
|
|
14
|
+
// `schemas.js` imports Zod, which is an OPTIONAL peer — a worker that only needs
|
|
15
|
+
// to respond should not have to load a 60 kB validator. But as long as this file
|
|
16
|
+
// reexported the schemas, the import was eager: `import { failure } from
|
|
17
|
+
// '@riligar/contract'` pulled in `schemas.js`, which pulled in Zod, and the
|
|
18
|
+
// install broke with "Cannot find package 'zod'" for anyone who treated the peer
|
|
19
|
+
// as optional. The optional peer was not optional.
|
|
20
20
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
21
|
+
// Whoever wants the schemas imports `@riligar/contract/schemas`, and there Zod
|
|
22
|
+
// is a legitimate requirement of that path — declared in the export map, not
|
|
23
|
+
// hidden in a chain of reexports.
|
|
24
24
|
export { CODES, STATUS_BY_CODE, RETRIABLE, isCode, toCode, codeFromStatus, ALIASES } from './codes.js'
|
|
25
|
-
export { ok,
|
|
25
|
+
export { ok, created, collection, failure, failureWithStatus, failureBody, pagination, page } from './responses.js'
|
package/src/responses.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// RESPONSES — the only way out of the server
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Four functions. If a handler in any of the seven products builds a
|
|
6
|
+
// `Response` by hand, it has stepped outside the contract.
|
|
7
|
+
//
|
|
8
|
+
// The point of this file is not to save typing — it is to make the defect
|
|
9
|
+
// IMPOSSIBLE. `failure()` derives the status from the code: there is no
|
|
10
|
+
// signature that lets you return `NOT_FOUND` with a 200. That discordant pair
|
|
11
|
+
// was exactly what made an agent read 200, conclude success, and carry on with
|
|
12
|
+
// data that did not exist.
|
|
13
|
+
import { CODES, STATUS_BY_CODE, RETRIABLE, isCode, toCode, codeFromStatus } from './codes.js'
|
|
14
|
+
|
|
15
|
+
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' }
|
|
16
|
+
|
|
17
|
+
const respond = (body, status, headers) => new Response(JSON.stringify(body), { status, headers: { ...JSON_HEADERS, ...headers } })
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One resource, at the root.
|
|
21
|
+
*
|
|
22
|
+
* ok({ id: 'mon_a1', name: 'checkout' })
|
|
23
|
+
* → 200 {"id":"mon_a1","name":"checkout"}
|
|
24
|
+
*
|
|
25
|
+
* No `data`, no `success`, no `message`. The HTTP status already said it went
|
|
26
|
+
* well; repeating that in the body is noise the agent has to learn to ignore —
|
|
27
|
+
* once per product.
|
|
28
|
+
*/
|
|
29
|
+
export const ok = (resource, { status = 200, headers } = {}) => respond(resource, status, headers)
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A resource that has just been BORN.
|
|
33
|
+
*
|
|
34
|
+
* 201 is the creation signal, and nothing else. An upsert that updated returns
|
|
35
|
+
* `ok()`; the one that created returns `created()`. That is what lets the agent
|
|
36
|
+
* know whether its own call was the one that created — without having to
|
|
37
|
+
* compare timestamps.
|
|
38
|
+
*/
|
|
39
|
+
export const created = (resource, { headers } = {}) => respond(resource, 201, headers)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A collection, always in the same shape.
|
|
43
|
+
*
|
|
44
|
+
* collection([a, b], { limit: 50, offset: 0, total: 128 })
|
|
45
|
+
* → {"items":[…],"page":{"limit":50,"offset":0,"total":128,"hasMore":true}}
|
|
46
|
+
*
|
|
47
|
+
* `items` is always an array — empty is a legitimate response and has the SAME
|
|
48
|
+
* shape as a full one. Pruning the empty list (which Payments used to do) broke
|
|
49
|
+
* on the very first client, because every test environment has seeded data.
|
|
50
|
+
*
|
|
51
|
+
* `hasMore` is explicit on purpose: the consumer should not have to compare
|
|
52
|
+
* `offset + limit` against `total` to know whether to keep paginating.
|
|
53
|
+
*
|
|
54
|
+
* ────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
* `total: null` WHEN NOBODY COUNTED — and that is the whole point of this helper.
|
|
56
|
+
*
|
|
57
|
+
* Until 09/10 the defaults filled `page` from the array itself: a route that
|
|
58
|
+
* NEVER paginated returned `{limit: N, offset: 0, total: N, hasMore: false}`,
|
|
59
|
+
* byte for byte identical to a route that paginated and reached the end. Of the
|
|
60
|
+
* stack's 39 collection routes, 29 did not paginate and all 29 asserted
|
|
61
|
+
* `hasMore: false` — the very field that exists so the agent need not deduce
|
|
62
|
+
* was deducing wrong in 87% of cases.
|
|
63
|
+
*
|
|
64
|
+
* Now: whoever does not pass `total` gets `total: null` and `hasMore: null`.
|
|
65
|
+
* Null means "I don't know", and that differs from zero and from false. An
|
|
66
|
+
* agent reading `total: null` knows it must ask another way; one reading
|
|
67
|
+
* `hasMore: false` stops paginating — and those two conclusions must not come
|
|
68
|
+
* out of the same response.
|
|
69
|
+
*
|
|
70
|
+
* Whoever truly paginates passes `total` and gets `hasMore` computed. Whoever
|
|
71
|
+
* returns the whole list on purpose passes `total: items.length`, and then
|
|
72
|
+
* `hasMore: false` is an assertion, not a default.
|
|
73
|
+
*/
|
|
74
|
+
export const collection = (list, page = {}, { status = 200, headers } = {}) => {
|
|
75
|
+
const items = Array.isArray(list) ? list : []
|
|
76
|
+
const limit = page.limit ?? items.length
|
|
77
|
+
const offset = page.offset ?? 0
|
|
78
|
+
|
|
79
|
+
// `null` only when NOBODY counted. `total: 0` is a legitimate count.
|
|
80
|
+
const total = page.total ?? null
|
|
81
|
+
const hasMore = page.hasMore ?? (total === null ? null : offset + items.length < total)
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* `extra` goes at the ROOT, not inside `page`. Hoster's `activeDeployId`
|
|
85
|
+
* and Monitors' `counts` are context about the SET, not about pagination —
|
|
86
|
+
* inside `page` they would claim to be pagination, and a generic client
|
|
87
|
+
* reading `page` to paginate would trip over them.
|
|
88
|
+
*/
|
|
89
|
+
return respond({ items, page: { limit, offset, total, hasMore }, ...(page.extra ?? {}) }, status, headers)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A failure. The status comes from the CODE — the caller does not pick the number.
|
|
94
|
+
*
|
|
95
|
+
* failure(CODES.NOT_FOUND, 'Project not found.')
|
|
96
|
+
* → 404 {"error":{"code":"NOT_FOUND","message":"Project not found."}}
|
|
97
|
+
*
|
|
98
|
+
* `details` is for what the agent can ACT on: the limit that was exceeded, the
|
|
99
|
+
* field that was missing, how many seconds to wait. Never for a stack trace.
|
|
100
|
+
*
|
|
101
|
+
* An unknown code becomes INTERNAL_ERROR rather than a silent 500 with a
|
|
102
|
+
* malformed body: if someone wrote a code outside the enum, the bug is ours,
|
|
103
|
+
* and the response to the client stays well formed.
|
|
104
|
+
*/
|
|
105
|
+
export const failure = (code, message, { details, headers, status } = {}) => {
|
|
106
|
+
const resolved = toCode(code)
|
|
107
|
+
const body = { error: { code: resolved, message: String(message ?? '') } }
|
|
108
|
+
|
|
109
|
+
if (details && Object.keys(details).length) body.error.details = details
|
|
110
|
+
if (RETRIABLE.has(resolved)) body.error.retriable = true
|
|
111
|
+
|
|
112
|
+
return respond(body, status ?? STATUS_BY_CODE[resolved], headers)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The error body, without the `Response` around it.
|
|
117
|
+
*
|
|
118
|
+
* For whoever answers through a framework that assembles the response itself —
|
|
119
|
+
* the Elysia of Storage and Functions, where the handler returns an object and
|
|
120
|
+
* the status travels via `set.status`. Returns `{ body, status }` so the two go
|
|
121
|
+
* together and there is no way to forget one.
|
|
122
|
+
*/
|
|
123
|
+
export const failureBody = (code, message, details) => {
|
|
124
|
+
const resolved = toCode(code)
|
|
125
|
+
const error = { code: resolved, message: String(message ?? '') }
|
|
126
|
+
|
|
127
|
+
if (details && Object.keys(details).length) error.details = details
|
|
128
|
+
if (RETRIABLE.has(resolved)) error.retriable = true
|
|
129
|
+
|
|
130
|
+
return { body: { error }, status: STATUS_BY_CODE[resolved] }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Reads `limit`/`offset` from a URL, with a ceiling.
|
|
135
|
+
*
|
|
136
|
+
* A `limit` with no ceiling is a way to take the service down with one query; a
|
|
137
|
+
* ceiling with no `offset` is what made record 101 of Messages unreachable. The
|
|
138
|
+
* two travel together.
|
|
139
|
+
*/
|
|
140
|
+
export const pagination = (url, { defaultLimit = 50, maxLimit = 200 } = {}) => {
|
|
141
|
+
const p = url instanceof URL ? url.searchParams : new URL(url, 'http://x').searchParams
|
|
142
|
+
const rawLimit = Number(p.get('limit'))
|
|
143
|
+
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), maxLimit) : defaultLimit
|
|
144
|
+
const rawOffset = Number(p.get('offset'))
|
|
145
|
+
const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0
|
|
146
|
+
return { limit, offset }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The page of a paginated query, ready for `collection`.
|
|
151
|
+
*
|
|
152
|
+
* It exists to make MECHANICAL what the stack got wrong by hand on two fronts:
|
|
153
|
+
*
|
|
154
|
+
* 1. **The reported `limit` must be the APPLIED one.** Two routes clamped in
|
|
155
|
+
* the data layer and reported the raw query value: with `?limit=5000` they
|
|
156
|
+
* returned 200 rows and said `page:{limit:5000, hasMore:false}`. The client
|
|
157
|
+
* concludes "I asked for 5000, got 200, therefore it's over" — and stops
|
|
158
|
+
* paginating halfway.
|
|
159
|
+
*
|
|
160
|
+
* 2. **`total` comes from whoever counted.** Passing `total` is mandatory here,
|
|
161
|
+
* because `collection` on its own has no way to tell whether nobody counted
|
|
162
|
+
* or the count happened to equal the array length.
|
|
163
|
+
*
|
|
164
|
+
* const { limit, offset } = pagination(url)
|
|
165
|
+
* const rows = await fetchRows(limit, offset)
|
|
166
|
+
* const [{ total }] = await count()
|
|
167
|
+
* return collection(rows, page({ limit, offset, total }))
|
|
168
|
+
*/
|
|
169
|
+
export const page = ({ limit, offset = 0, total, extra }) => {
|
|
170
|
+
if (!Number.isFinite(total)) {
|
|
171
|
+
throw new TypeError('page() requires a numeric `total` — use collection(items) directly when nobody counted.')
|
|
172
|
+
}
|
|
173
|
+
return { limit, offset, total, extra }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A failure for which only the HTTP status is known.
|
|
178
|
+
*
|
|
179
|
+
* For the global error handler, where a `throw { status, message }` arrives
|
|
180
|
+
* from any layer and there is no code at hand. It derives the `code` from the
|
|
181
|
+
* number instead of always falling into `INTERNAL_ERROR` — a 401 that turns
|
|
182
|
+
* into a 500 at the edge hides the real problem from whoever is debugging.
|
|
183
|
+
*
|
|
184
|
+
* `status` is preserved: whoever already had the number keeps it, even when the
|
|
185
|
+
* canonical code maps to another (401 for `UNAUTHORIZED`, 402 for `PLAN_LIMIT`).
|
|
186
|
+
*/
|
|
187
|
+
export const failureWithStatus = (status, message, details, headers) => failure(codeFromStatus(status), message, { details, headers, status })
|
|
188
|
+
|
|
189
|
+
export { CODES, STATUS_BY_CODE }
|
package/src/schemas.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
// ============================================================================
|
|
2
|
-
// SCHEMAS —
|
|
2
|
+
// SCHEMAS — the contract as an executable thing
|
|
3
3
|
// ============================================================================
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// The same rules as `responses.js`, from the READER's side. They let the
|
|
6
|
+
// contract test assert the shape without reimplementing the assertion in every
|
|
7
|
+
// suite, and let a consumer validate what it received.
|
|
8
8
|
//
|
|
9
|
-
// Zod
|
|
10
|
-
// worker
|
|
9
|
+
// Zod is a peer: the seven products already have it (v4), and embedding a
|
|
10
|
+
// second copy in a worker means paying the bundle cost twice.
|
|
11
11
|
import { z } from 'zod'
|
|
12
12
|
|
|
13
13
|
import { STATUS_BY_CODE } from './codes.js'
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/** The code is the closed enum — no free-form strings. */
|
|
16
16
|
export const codeSchema = z.enum(Object.keys(STATUS_BY_CODE))
|
|
17
17
|
|
|
18
18
|
export const errorSchema = z.object({
|
|
@@ -39,42 +39,43 @@ export const collectionSchema = item =>
|
|
|
39
39
|
})
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
42
|
+
* The resource goes at the root, so there is no envelope to validate — what can
|
|
43
|
+
* be asserted is what it must NOT be.
|
|
44
44
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
45
|
+
* The three forbidden keys are exactly the envelopes the stack used to use. A
|
|
46
|
+
* resource that still carries `data`, `success` or `message` at the top is a
|
|
47
|
+
* product that has not migrated — and the contract test must say so in plain
|
|
48
|
+
* words, instead of passing because "an object came back".
|
|
49
49
|
*/
|
|
50
|
-
export const resourceSchema =
|
|
51
|
-
(
|
|
52
|
-
message: '
|
|
50
|
+
export const resourceSchema = body =>
|
|
51
|
+
(body ?? z.object({}).loose()).refine(v => !v || typeof v !== 'object' || (!('data' in v) && !('success' in v) && !('message' in v)), {
|
|
52
|
+
message: 'a resource must not be wrapped in `data`, `success` or `message`',
|
|
53
53
|
})
|
|
54
54
|
|
|
55
55
|
const CAMEL = /^[a-z][a-zA-Z0-9]*$/
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
58
|
+
* Every key in the body is camelCase, at any depth.
|
|
59
59
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
60
|
+
* Storage used to return `tenant_id` in the body and `tenantId` in the path of
|
|
61
|
+
* the SAME route. A consumer had to know, field by field, which vocabulary to
|
|
62
|
+
* use.
|
|
62
63
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
64
|
+
* Keys that are user DATA (the name of a collection, of an environment
|
|
65
|
+
* variable, of a header) do not follow the rule — they are not our contract.
|
|
66
|
+
* Hence `ignore`.
|
|
66
67
|
*/
|
|
67
|
-
export const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
if (Array.isArray(v)) return v.forEach((x, i) =>
|
|
68
|
+
export const camelCaseKeys = (value, { ignore = [] } = {}, path = '') => {
|
|
69
|
+
const offenders = []
|
|
70
|
+
const walk = (v, path) => {
|
|
71
|
+
if (Array.isArray(v)) return v.forEach((x, i) => walk(x, `${path}[${i}]`))
|
|
71
72
|
if (!v || typeof v !== 'object') return
|
|
72
73
|
for (const [k, sub] of Object.entries(v)) {
|
|
73
|
-
const
|
|
74
|
-
if (!
|
|
75
|
-
|
|
74
|
+
const here = path ? `${path}.${k}` : k
|
|
75
|
+
if (!ignore.includes(k) && !CAMEL.test(k)) offenders.push(here)
|
|
76
|
+
walk(sub, here)
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
|
-
|
|
79
|
-
return
|
|
79
|
+
walk(value, path)
|
|
80
|
+
return offenders
|
|
80
81
|
}
|
package/src/respostas.js
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
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 }
|