@voxgig/sdkgen 4.2.6 → 4.2.7
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/bin/voxgig-sdkgen +1 -1
- package/dist/helpers/naming.js +5 -4
- package/dist/helpers/naming.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/project/.sdk/model/feature/cost.aon +43 -0
- package/project/.sdk/model/feature/feature-index.aon +1 -0
- package/project/.sdk/src/cmp/js/fragment/EntityCreateOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/js/fragment/EntityListOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/js/fragment/EntityLoadOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/js/fragment/EntityRemoveOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/js/fragment/EntityUpdateOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/py/ReadmeExamplesTest_py.ts +15 -5
- package/project/.sdk/src/cmp/ts/fragment/EntityCreateOp.fragment.ts +2 -1
- package/project/.sdk/src/cmp/ts/fragment/EntityListOp.fragment.ts +2 -1
- package/project/.sdk/src/cmp/ts/fragment/EntityLoadOp.fragment.ts +2 -1
- package/project/.sdk/src/cmp/ts/fragment/EntityRemoveOp.fragment.ts +2 -1
- package/project/.sdk/src/cmp/ts/fragment/EntityUpdateOp.fragment.ts +2 -1
- package/project/.sdk/tm/c/core/sdk.h +1 -0
- package/project/.sdk/tm/c/feature/cost.c +549 -0
- package/project/.sdk/tm/c/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/clojure/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/cpp/feature/cost.hpp +419 -0
- package/project/.sdk/tm/cpp/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/csharp/feature/CostFeature.cs +518 -0
- package/project/.sdk/tm/csharp/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/dart/lib/feature/cost/CostFeature.dart +446 -0
- package/project/.sdk/tm/dart/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/elixir/lib/projectname/feature/cost.ex +442 -0
- package/project/.sdk/tm/elixir/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/go/feature/cost_feature.go +424 -0
- package/project/.sdk/tm/go/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/go-cli/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/go-mcp/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/java/feature/CostFeature.java +431 -0
- package/project/.sdk/tm/java/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/js/src/feature/cost/CostFeature.js +413 -0
- package/project/.sdk/tm/js/test/feature/Corpus.test.js +264 -0
- package/project/.sdk/tm/kotlin/feature/CostFeature.kt +430 -0
- package/project/.sdk/tm/kotlin/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/lean/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/lua/feature/cost_feature.lua +457 -0
- package/project/.sdk/tm/lua/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/ocaml/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/perl/feature/cost_feature.pm +446 -0
- package/project/.sdk/tm/perl/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/php/feature/CostFeature.php +439 -0
- package/project/.sdk/tm/php/feature/TestFeature.php +31 -1
- package/project/.sdk/tm/php/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/py/pkg/feature/cost_feature.py +381 -0
- package/project/.sdk/tm/py/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/py-data/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/rb/feature/cost_feature.rb +386 -0
- package/project/.sdk/tm/rb/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/rust/feature/cost.rs +467 -0
- package/project/.sdk/tm/rust/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/scala/feature/CostFeature.scala +388 -0
- package/project/.sdk/tm/scala/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/swift/Sources/ProjectNameSDK/feature/CostFeature.swift +425 -0
- package/project/.sdk/tm/swift/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/ts/src/feature/cost/CostFeature.ts +415 -0
- package/project/.sdk/tm/ts/test/feature/Corpus.test.ts +266 -0
- package/project/.sdk/tm/zig/feature/cost.zig +457 -0
- package/project/.sdk/tm/zig/src/feature/cost/.gitkeep +0 -0
- package/project/sdkgen-package.json +2 -1
- package/src/helpers/naming.ts +5 -4
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
const { BaseFeature } = require('../base/BaseFeature')
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
// Cost tracking and spend budget. Uses BOTH seams, which is the point of
|
|
7
|
+
// the feature: money is spent per HTTP ATTEMPT (a retried call is charged
|
|
8
|
+
// again, because the upstream API charges it again), but it is owed by an
|
|
9
|
+
// OPERATION. So the transport wrap prices each attempt, and PreDone
|
|
10
|
+
// attributes the running total to `<entity>.<op>` and to the caller
|
|
11
|
+
// (`ctrl.actor`, the same actor the audit feature records).
|
|
12
|
+
//
|
|
13
|
+
// The price of an attempt comes from the first source that answers:
|
|
14
|
+
// a response header (`header` x `perUnit`), the rate table (`rates`, keyed
|
|
15
|
+
// '<entity>.<op>' / '<op>' / '*'), then the flat `unit`. A body figure
|
|
16
|
+
// (`path` x `perUnit`, e.g. 'usage.total_tokens') is read at PreDone
|
|
17
|
+
// instead, from the already-parsed result: the response body is a one-shot
|
|
18
|
+
// stream, and consuming it at the transport seam would leave the pipeline
|
|
19
|
+
// with nothing. A body figure describes the whole call, so it REPLACES the
|
|
20
|
+
// per-attempt estimate rather than adding to it.
|
|
21
|
+
//
|
|
22
|
+
// `budget` caps total spend. With `onBudget: 'deny'` a further operation is
|
|
23
|
+
// refused at PrePoint, before an endpoint is resolved and before anything
|
|
24
|
+
// reaches the network.
|
|
25
|
+
//
|
|
26
|
+
// ORDER MATTERS. Cost must sit INSIDE the cache, or a response served from
|
|
27
|
+
// cache is charged for money that was never spent. The default (map) order
|
|
28
|
+
// puts cache innermost and cost outside it, so activate them in array form
|
|
29
|
+
// with cost first: [{ name: 'cost' }, { name: 'cache' }].
|
|
30
|
+
class CostFeature extends BaseFeature {
|
|
31
|
+
version = '0.0.1'
|
|
32
|
+
name = 'cost'
|
|
33
|
+
active = true
|
|
34
|
+
|
|
35
|
+
_client
|
|
36
|
+
_options = {}
|
|
37
|
+
_pending = new WeakMap()
|
|
38
|
+
_seq = 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
init(ctx, options) {
|
|
42
|
+
this._client = ctx.client
|
|
43
|
+
this._options = options || {}
|
|
44
|
+
this.active = options.active
|
|
45
|
+
this._pending = new WeakMap()
|
|
46
|
+
this._seq = 0
|
|
47
|
+
|
|
48
|
+
const limit = this._limit()
|
|
49
|
+
const client = this._client
|
|
50
|
+
if (null == client._cost) {
|
|
51
|
+
client._cost = {
|
|
52
|
+
currency: this._options.currency || 'USD',
|
|
53
|
+
total: { calls: 0, attempts: 0, amount: 0, reported: 0, estimated: 0 },
|
|
54
|
+
ops: {},
|
|
55
|
+
actors: {},
|
|
56
|
+
budget: { limit, spent: 0, remaining: limit, exceeded: false },
|
|
57
|
+
last: undefined,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!this.active) {
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const self = this
|
|
66
|
+
const utility = ctx.utility
|
|
67
|
+
const inner = utility.fetcher
|
|
68
|
+
|
|
69
|
+
utility.fetcher = async function (ctx2, url, fetchdef) {
|
|
70
|
+
return self._charge(ctx2, url, fetchdef, inner)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
// Budget gate. Runs before endpoint resolution, so a refused call costs
|
|
76
|
+
// nothing at all.
|
|
77
|
+
PrePoint(ctx) {
|
|
78
|
+
if (!this.active) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Mark the context as running through the pipeline, so _charge knows a
|
|
83
|
+
// PreDone is coming and does not commit the spend itself.
|
|
84
|
+
let pending = this._pending.get(ctx)
|
|
85
|
+
if (null == pending) {
|
|
86
|
+
pending = this._newPending()
|
|
87
|
+
this._pending.set(ctx, pending)
|
|
88
|
+
}
|
|
89
|
+
pending.piped = true
|
|
90
|
+
|
|
91
|
+
const limit = this._limit()
|
|
92
|
+
if (0 >= limit) {
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const client = this._client
|
|
97
|
+
const cost = client._cost
|
|
98
|
+
if (cost.total.amount < limit) {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
cost.budget.exceeded = true
|
|
103
|
+
|
|
104
|
+
if ('deny' !== this._options.onBudget) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const err = ctx.error('cost_budget',
|
|
109
|
+
'Cost budget of ' + limit + ' ' + cost.currency + ' is spent (' +
|
|
110
|
+
cost.total.amount + ' ' + cost.currency + ' used)')
|
|
111
|
+
// Short-circuit endpoint resolution; the pipeline surfaces this error.
|
|
112
|
+
ctx.out.point = err
|
|
113
|
+
return err
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async _charge(ctx, url, fetchdef, inner) {
|
|
118
|
+
let res
|
|
119
|
+
let threw = false
|
|
120
|
+
|
|
121
|
+
// A rejecting transport still costs an attempt. Without this, a run of
|
|
122
|
+
// connection-level failures under `retry` (which catches the throw and
|
|
123
|
+
// tries again) would be charged nothing at all, and an onBudget: 'deny'
|
|
124
|
+
// ceiling could never stop it.
|
|
125
|
+
try {
|
|
126
|
+
res = await inner(ctx, url, fetchdef)
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
threw = true
|
|
130
|
+
res = err
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const priced = this._price(ctx, res)
|
|
134
|
+
const client = this._client
|
|
135
|
+
const cost = client._cost
|
|
136
|
+
|
|
137
|
+
let pending = this._pending.get(ctx)
|
|
138
|
+
if (null == pending) {
|
|
139
|
+
pending = this._newPending()
|
|
140
|
+
this._pending.set(ctx, pending)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
pending.attempts++
|
|
144
|
+
|
|
145
|
+
// Accumulated here, committed once at PreDone. Adding each attempt to
|
|
146
|
+
// the running total and then subtracting it again when a body figure
|
|
147
|
+
// supersedes it loses precision to catastrophic cancellation
|
|
148
|
+
// (5 + (0.01 - 5) is not 0.01 in binary floating point).
|
|
149
|
+
//
|
|
150
|
+
// Reported and estimated are kept apart per ATTEMPT, not per operation:
|
|
151
|
+
// a 503 priced from the rate table followed by a 200 carrying the cost
|
|
152
|
+
// header is part estimate, part reported, and collapsing both into the
|
|
153
|
+
// final attempt's category would corrupt the split.
|
|
154
|
+
pending.amount += priced.amount
|
|
155
|
+
pending[('header' === priced.source || 'body' === priced.source) ?
|
|
156
|
+
'reported' : 'estimated'] += priced.amount
|
|
157
|
+
pending.source = priced.source
|
|
158
|
+
|
|
159
|
+
cost.total.attempts++
|
|
160
|
+
|
|
161
|
+
// direct() and graphql() call the transport through _rawRequest, which
|
|
162
|
+
// dispatches no pipeline hooks at all — no PrePoint to gate on, and no
|
|
163
|
+
// PreDone to commit. Their spend is committed here instead, or it would
|
|
164
|
+
// never be counted and could run past an onBudget: 'deny' ceiling
|
|
165
|
+
// indefinitely. `piped` is set by PrePoint, so its absence is the signal.
|
|
166
|
+
if (!pending.piped) {
|
|
167
|
+
this._commit(ctx, pending, '_', 'direct')
|
|
168
|
+
this._pending.delete(ctx)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (threw) {
|
|
172
|
+
throw res
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return res
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
_newPending() {
|
|
180
|
+
return { attempts: 0, amount: 0, reported: 0, estimated: 0, source: 'none', piped: false }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
// Attribute the operation's spend once the call is finished.
|
|
185
|
+
PreDone(ctx) {
|
|
186
|
+
this._finish(ctx, true)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
// A failed operation still spent the money. When the pipeline throws,
|
|
191
|
+
// PreDone never runs, so without this the attempts are counted and the
|
|
192
|
+
// spend is not — and a budget could never see the cost of a call that
|
|
193
|
+
// failed. Committing is once-per-operation either way: whichever hook
|
|
194
|
+
// fires first consumes the pending entry.
|
|
195
|
+
PreUnexpected(ctx) {
|
|
196
|
+
this._finish(ctx, false)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_finish(ctx, done) {
|
|
201
|
+
if (!this.active) {
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
const pending = this._pending.get(ctx)
|
|
205
|
+
if (null == pending) {
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
this._pending.delete(ctx)
|
|
209
|
+
|
|
210
|
+
// A FAILED operation that made no attempt never reached the network:
|
|
211
|
+
// PrePoint creates the pending entry to mark the context as piped, and
|
|
212
|
+
// then the budget gate refuses the call (rbac, or an unresolvable
|
|
213
|
+
// endpoint, short-circuits just as early). Committing it would count a
|
|
214
|
+
// call that never happened and file a zero-amount record as `last`.
|
|
215
|
+
//
|
|
216
|
+
// A SUCCEEDED operation that made no attempt is the opposite case: it was
|
|
217
|
+
// served from the cache. That is a real call, and the fact that it cost
|
|
218
|
+
// nothing is the whole point of ordering cost inside the cache.
|
|
219
|
+
if (!done && 0 === pending.attempts) {
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const entity = (ctx.op && ctx.op.entity) || '_'
|
|
224
|
+
const opname = (ctx.op && ctx.op.name) || '_'
|
|
225
|
+
|
|
226
|
+
this._commit(ctx, pending, entity, opname)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
// Commit one operation's spend: totals, budget, per-op and per-actor
|
|
231
|
+
// attribution, and the record. Shared by PreDone and the raw-request path
|
|
232
|
+
// in _charge, which has no PreDone to reach.
|
|
233
|
+
_commit(ctx, pending, entity, opname) {
|
|
234
|
+
const client = this._client
|
|
235
|
+
const cost = client._cost
|
|
236
|
+
|
|
237
|
+
let amount = pending.amount
|
|
238
|
+
let reported = pending.reported
|
|
239
|
+
let estimated = pending.estimated
|
|
240
|
+
let source = pending.source
|
|
241
|
+
|
|
242
|
+
// A body figure prices the whole call, so it replaces the per-attempt
|
|
243
|
+
// estimate rather than adding to it — and, being server-stated, the
|
|
244
|
+
// whole amount counts as reported.
|
|
245
|
+
const body = this._body(ctx)
|
|
246
|
+
if (null != body) {
|
|
247
|
+
amount = body
|
|
248
|
+
reported = body
|
|
249
|
+
estimated = 0
|
|
250
|
+
source = 'body'
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
this._spend(cost, amount, reported, estimated)
|
|
254
|
+
const actor = (ctx.ctrl && ctx.ctrl.actor) || this._options.actor || 'anonymous'
|
|
255
|
+
|
|
256
|
+
cost.total.calls++
|
|
257
|
+
this._bump(cost.ops, entity + '.' + opname, amount)
|
|
258
|
+
this._bump(cost.actors, actor, amount)
|
|
259
|
+
|
|
260
|
+
this._seq++
|
|
261
|
+
const record = {
|
|
262
|
+
seq: this._seq,
|
|
263
|
+
entity,
|
|
264
|
+
op: opname,
|
|
265
|
+
actor,
|
|
266
|
+
amount,
|
|
267
|
+
currency: cost.currency,
|
|
268
|
+
source,
|
|
269
|
+
attempts: pending.attempts,
|
|
270
|
+
}
|
|
271
|
+
cost.last = record
|
|
272
|
+
|
|
273
|
+
const sink = this._options.sink
|
|
274
|
+
if ('function' === typeof sink) {
|
|
275
|
+
try { sink(record) } catch (_e) { }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
// Price one attempt: a reported header figure, else the rate table, else
|
|
281
|
+
// the flat unit.
|
|
282
|
+
_price(ctx, res) {
|
|
283
|
+
const header = this._options.header
|
|
284
|
+
if ('string' === typeof header && '' !== header) {
|
|
285
|
+
const v = this._header(res, header)
|
|
286
|
+
if (null != v) {
|
|
287
|
+
return { amount: v * this._perUnit(), source: 'header' }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const rate = this._rate(ctx)
|
|
292
|
+
if (null != rate) {
|
|
293
|
+
return { amount: rate, source: 'table' }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const unit = this._options.unit
|
|
297
|
+
if ('number' === typeof unit && 0 !== unit) {
|
|
298
|
+
return { amount: unit, source: 'unit' }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { amount: 0, source: 'none' }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
// The rate table uses the same lookup grammar as rbac's rules:
|
|
306
|
+
// '<entity>.<op>', then '<op>', then '*'.
|
|
307
|
+
_rate(ctx) {
|
|
308
|
+
const rates = this._options.rates || {}
|
|
309
|
+
const entity = (ctx.entity && ctx.entity.name) || (ctx.op && ctx.op.entity) || ''
|
|
310
|
+
const opname = (ctx.op && ctx.op.name) || ''
|
|
311
|
+
|
|
312
|
+
if ('number' === typeof rates[entity + '.' + opname]) {
|
|
313
|
+
return rates[entity + '.' + opname]
|
|
314
|
+
}
|
|
315
|
+
if ('number' === typeof rates[opname]) {
|
|
316
|
+
return rates[opname]
|
|
317
|
+
}
|
|
318
|
+
if ('number' === typeof rates['*']) {
|
|
319
|
+
return rates['*']
|
|
320
|
+
}
|
|
321
|
+
return null
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
// A usage figure from the parsed result body, priced by perUnit. Read
|
|
326
|
+
// here, not at the transport seam, because the body is one-shot.
|
|
327
|
+
_body(ctx) {
|
|
328
|
+
const path = this._options.path
|
|
329
|
+
if ('string' !== typeof path || '' === path) {
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
const result = ctx.result
|
|
333
|
+
if (null == result || null == result.body || 'object' !== typeof result.body) {
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
const v = ctx.utility.struct.getpath(result.body, path)
|
|
337
|
+
const n = Number(v)
|
|
338
|
+
if (null == v || isNaN(n)) {
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
return n * this._perUnit()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
_spend(cost, amount, reported, estimated) {
|
|
346
|
+
cost.total.amount += amount
|
|
347
|
+
cost.total.reported += reported
|
|
348
|
+
cost.total.estimated += estimated
|
|
349
|
+
|
|
350
|
+
const limit = cost.budget.limit
|
|
351
|
+
cost.budget.spent = cost.total.amount
|
|
352
|
+
cost.budget.remaining = 0 < limit ? Math.max(0, limit - cost.total.amount) : 0
|
|
353
|
+
if (0 < limit && cost.total.amount >= limit) {
|
|
354
|
+
cost.budget.exceeded = true
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
_bump(bucket, key, amount) {
|
|
360
|
+
let b = bucket[key]
|
|
361
|
+
if (null == b) {
|
|
362
|
+
b = bucket[key] = { calls: 0, amount: 0 }
|
|
363
|
+
}
|
|
364
|
+
b.calls++
|
|
365
|
+
b.amount += amount
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
_header(res, name) {
|
|
370
|
+
if (null == res || null == res.headers) {
|
|
371
|
+
return null
|
|
372
|
+
}
|
|
373
|
+
let v
|
|
374
|
+
if ('function' === typeof res.headers.get) {
|
|
375
|
+
v = res.headers.get(name.toLowerCase())
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
// A plain header map from a custom system.fetch keeps conventional
|
|
379
|
+
// casing ('X-Request-Cost'), and HTTP header names are
|
|
380
|
+
// case-insensitive, so scan rather than index. The go, perl and php
|
|
381
|
+
// ports already do this.
|
|
382
|
+
const lower = name.toLowerCase()
|
|
383
|
+
for (const k of Object.keys(res.headers)) {
|
|
384
|
+
if (k.toLowerCase() === lower) {
|
|
385
|
+
v = res.headers[k]
|
|
386
|
+
break
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (null == v) {
|
|
391
|
+
return null
|
|
392
|
+
}
|
|
393
|
+
const n = Number(v)
|
|
394
|
+
return isNaN(n) ? null : n
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
_perUnit() {
|
|
399
|
+
const p = this._options.perUnit
|
|
400
|
+
return 'number' === typeof p ? p : 0
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
_limit() {
|
|
405
|
+
const b = this._options.budget
|
|
406
|
+
return 'number' === typeof b ? b : 0
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
module.exports = {
|
|
412
|
+
CostFeature
|
|
413
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
|
|
2
|
+
// Feature behaviour, driven by the SHARED corpus.
|
|
3
|
+
//
|
|
4
|
+
// This is the route PrimaryUtility.test.ts already takes for the utilities:
|
|
5
|
+
// language-neutral cases in .sdk/test/test.json, executed against the REAL
|
|
6
|
+
// generated SDK. Features here are ordinary classes in ordinary compiled
|
|
7
|
+
// source, unit-tested the ordinary way — no transpiled templates, and no
|
|
8
|
+
// miniature of the pipeline standing in for the pipeline (which is what
|
|
9
|
+
// harness.ts does, and why its assertions can only be as right as the
|
|
10
|
+
// miniature is). A feature is built through the generated config, wrapped
|
|
11
|
+
// into a client built by the generated constructor, and driven by a real
|
|
12
|
+
// entity operation. What is asserted is what ships.
|
|
13
|
+
//
|
|
14
|
+
// Everything in a case is data: features are activated by name, options are
|
|
15
|
+
// plain JSON, the transport is scripted by `res`, and the assertion is a
|
|
16
|
+
// subset of the client's own record. Turning `res` into a fetcher is the one
|
|
17
|
+
// piece each language writes for itself.
|
|
18
|
+
//
|
|
19
|
+
// The ts twin of this file is the reference; keep the two in step.
|
|
20
|
+
|
|
21
|
+
const { test, describe, before } = require('node:test')
|
|
22
|
+
const { ok, deepStrictEqual } = require('node:assert')
|
|
23
|
+
|
|
24
|
+
const { readFileSync } = require('node:fs')
|
|
25
|
+
const { join } = require('node:path')
|
|
26
|
+
|
|
27
|
+
const { SDK, TEST_JSON_FILE } = require('../utility/index')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
// Features with a corpus section. A name here with no section is a skip, not
|
|
31
|
+
// a failure: an SDK generated without the feature has nothing to run.
|
|
32
|
+
const FEATURES = ['cost']
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
// One operation this SDK can actually perform is described by
|
|
36
|
+
// { key, accessor, entity, op }: `key` is '<entity>.<op>', how features
|
|
37
|
+
// attribute spend, and `accessor` is the client method returning the entity.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
// A scripted transport built from a case's `res` list. Responses are consumed
|
|
41
|
+
// in order and the last one repeats, so a case that does not care how many
|
|
42
|
+
// attempts happen need only declare one.
|
|
43
|
+
function scriptedFetcher(res) {
|
|
44
|
+
let n = -1
|
|
45
|
+
return async function (_ctx, _url, _fetchdef) {
|
|
46
|
+
n++
|
|
47
|
+
const spec = res[n < res.length ? n : res.length - 1] || {}
|
|
48
|
+
|
|
49
|
+
if (true === spec.throw) {
|
|
50
|
+
throw new Error('scripted transport failure')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const headers = spec.headers || {}
|
|
54
|
+
const status = null == spec.status ? 200 : spec.status
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
status,
|
|
58
|
+
statusText: status < 400 ? 'OK' : 'ERR',
|
|
59
|
+
body: 'not-used',
|
|
60
|
+
json: async () => (undefined === spec.body ? {} : spec.body),
|
|
61
|
+
headers: {
|
|
62
|
+
get(key) {
|
|
63
|
+
const lower = String(key).toLowerCase()
|
|
64
|
+
for (const k of Object.keys(headers)) {
|
|
65
|
+
if (k.toLowerCase() === lower) { return headers[k] }
|
|
66
|
+
}
|
|
67
|
+
return undefined
|
|
68
|
+
},
|
|
69
|
+
forEach(cb) { Object.keys(headers).forEach((k) => cb(headers[k], k, this)) },
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
function makeClient(kase) {
|
|
77
|
+
return new SDK({
|
|
78
|
+
feature: kase.feature,
|
|
79
|
+
utility: { fetcher: scriptedFetcher(kase.res || [{ status: 200, body: {} }]) },
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
// Every operation this SDK declares, in a stable order.
|
|
85
|
+
//
|
|
86
|
+
// The corpus cannot name an entity — it is shared by SDKs that have none in
|
|
87
|
+
// common — so the runner finds them here. The generated client exposes one
|
|
88
|
+
// capitalised, zero-argument accessor per entity, and the entity it returns
|
|
89
|
+
// carries the same `name` the config is keyed by; that pairing is what turns
|
|
90
|
+
// a config entry back into a callable method.
|
|
91
|
+
function candidates(client) {
|
|
92
|
+
const entities = client._rootctx.config.entity || {}
|
|
93
|
+
|
|
94
|
+
const accessor = {}
|
|
95
|
+
for (const m of Object.getOwnPropertyNames(Object.getPrototypeOf(client))) {
|
|
96
|
+
if (!/^[A-Z]/.test(m) || 'function' !== typeof client[m]) { continue }
|
|
97
|
+
let inst
|
|
98
|
+
try { inst = client[m]() }
|
|
99
|
+
catch (e) { continue }
|
|
100
|
+
if (null != inst && 'string' === typeof inst.name && null != entities[inst.name]) {
|
|
101
|
+
accessor[inst.name] = m
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const out = []
|
|
106
|
+
for (const entity of Object.keys(entities).sort()) {
|
|
107
|
+
if (null == accessor[entity]) { continue }
|
|
108
|
+
for (const op of Object.keys(entities[entity].op || {}).sort()) {
|
|
109
|
+
out.push({ key: entity + '.' + op, accessor: accessor[entity], entity, op })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
// Pick operations the corpus can drive, by DRIVING them: an op is usable when
|
|
117
|
+
// it completes against a plain 200 with no feature active. Declared ops are
|
|
118
|
+
// not all callable with no arguments (a required path parameter, a body), and
|
|
119
|
+
// a case that failed for that reason would look like a feature defect.
|
|
120
|
+
async function usableOps(want) {
|
|
121
|
+
const picked = []
|
|
122
|
+
for (const cand of candidates(makeClient({}))) {
|
|
123
|
+
const client = makeClient({})
|
|
124
|
+
try {
|
|
125
|
+
await client[cand.accessor]()[cand.op]({}, {})
|
|
126
|
+
}
|
|
127
|
+
catch (e) { continue }
|
|
128
|
+
picked.push(cand)
|
|
129
|
+
if (want <= picked.length) { break }
|
|
130
|
+
}
|
|
131
|
+
return picked
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
// Replace #OP1/#OP2 throughout a case, keys included.
|
|
136
|
+
function resolve(node, tokens) {
|
|
137
|
+
if ('string' === typeof node) {
|
|
138
|
+
let s = node
|
|
139
|
+
for (const t of Object.keys(tokens)) { s = s.split(t).join(tokens[t]) }
|
|
140
|
+
return s
|
|
141
|
+
}
|
|
142
|
+
if (Array.isArray(node)) {
|
|
143
|
+
return node.map((n) => resolve(n, tokens))
|
|
144
|
+
}
|
|
145
|
+
if (null != node && 'object' === typeof node) {
|
|
146
|
+
const out = {}
|
|
147
|
+
for (const k of Object.keys(node)) {
|
|
148
|
+
out[resolve(k, tokens)] = resolve(node[k], tokens)
|
|
149
|
+
}
|
|
150
|
+
return out
|
|
151
|
+
}
|
|
152
|
+
return node
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
// Which #OPn tokens a case uses. A case wanting more operations than this SDK
|
|
157
|
+
// has is skipped rather than failed.
|
|
158
|
+
function tokensUsed(kase) {
|
|
159
|
+
const m = JSON.stringify(kase).match(/#OP(\d+)/g) || []
|
|
160
|
+
return m.reduce((max, t) => Math.max(max, Number(t.slice(3))), 0)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
// Assert that `actual` contains `expect`, recursively. Cases assert only the
|
|
165
|
+
// fields they are about, so a full deepStrictEqual would force every case to
|
|
166
|
+
// restate the whole record.
|
|
167
|
+
function subset(actual, expect, path) {
|
|
168
|
+
if (null != expect && 'object' === typeof expect && !Array.isArray(expect)) {
|
|
169
|
+
for (const k of Object.keys(expect)) {
|
|
170
|
+
ok(null != actual, `${path}.${k}: nothing at ${path}`)
|
|
171
|
+
subset(actual[k], expect[k], `${path}.${k}`)
|
|
172
|
+
}
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
deepStrictEqual(actual, expect, path)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
describe('FeatureCorpus', () => {
|
|
180
|
+
|
|
181
|
+
let corpus
|
|
182
|
+
let ops = []
|
|
183
|
+
let byKey = {}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
before(async () => {
|
|
187
|
+
corpus = JSON.parse(readFileSync(join(__dirname, '..', TEST_JSON_FILE), 'utf8'))
|
|
188
|
+
ops = await usableOps(2)
|
|
189
|
+
byKey = {}
|
|
190
|
+
for (const o of ops) { byKey[o.key] = o }
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
test('the corpus carries a feature section', () => {
|
|
195
|
+
ok(null != corpus.feature,
|
|
196
|
+
'no `feature` section in test.json — recompile the corpus')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
// At least one operation, or every case below would skip and the whole
|
|
201
|
+
// suite would report green having run nothing.
|
|
202
|
+
test('this SDK has an operation the corpus can drive', () => {
|
|
203
|
+
ok(0 < ops.length,
|
|
204
|
+
'no declared operation completed against a plain 200 — the corpus ' +
|
|
205
|
+
'cannot exercise a feature without one')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
for (const name of FEATURES) {
|
|
210
|
+
|
|
211
|
+
test(name, async (t) => {
|
|
212
|
+
const section = corpus.feature?.[name]
|
|
213
|
+
if (null == section) {
|
|
214
|
+
return t.skip(`no corpus section for ${name}`)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const probe = makeClient({})
|
|
218
|
+
if (!probe._rootctx.config.hasFeature(name)) {
|
|
219
|
+
return t.skip(`this SDK was generated without the ${name} feature`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const cases = section.basic?.set || []
|
|
223
|
+
ok(0 < cases.length,
|
|
224
|
+
`corpus section feature.${name} ran ZERO cases — a renamed section ` +
|
|
225
|
+
`or an emptied fixture must fail loudly, not pass silently`)
|
|
226
|
+
|
|
227
|
+
let ran = 0
|
|
228
|
+
for (const raw of cases) {
|
|
229
|
+
const need = tokensUsed(raw)
|
|
230
|
+
if (ops.length < need) {
|
|
231
|
+
t.diagnostic(`skip "${raw.name}": needs ${need} operations, this SDK offers ${ops.length}`)
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const tokens = {}
|
|
236
|
+
for (let i = 0; i < need; i++) { tokens['#OP' + (i + 1)] = ops[i].key }
|
|
237
|
+
|
|
238
|
+
const kase = resolve(raw, tokens)
|
|
239
|
+
const client = makeClient(kase)
|
|
240
|
+
|
|
241
|
+
for (const step of (kase.op || [])) {
|
|
242
|
+
const ref = byKey[step.op]
|
|
243
|
+
ok(null != ref, `${kase.name}: no operation ${step.op}`)
|
|
244
|
+
try {
|
|
245
|
+
await client[ref.accessor]()[ref.op]({}, step.ctrl || {})
|
|
246
|
+
ok(null == step.err,
|
|
247
|
+
`${kase.name}: ${step.op} was expected to fail, and did not`)
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
if (null == step.err) { throw err }
|
|
251
|
+
if ('string' === typeof step.err) {
|
|
252
|
+
deepStrictEqual(err.code, step.err, `${kase.name}: wrong error code`)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
subset(client[`_${name}`], kase.out, `${kase.name}: _${name}`)
|
|
258
|
+
ran++
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
ok(0 < ran, `every feature.${name} case was skipped`)
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
})
|