@voxgig/sdkgen 4.4.1 → 4.5.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/bin/voxgig-sdkgen +1 -1
- package/dist/cmp/FeatureDocs.d.ts +16 -0
- package/dist/cmp/FeatureDocs.js +72 -0
- package/dist/cmp/FeatureDocs.js.map +1 -0
- package/dist/cmp/Readme.js +2 -0
- package/dist/cmp/Readme.js.map +1 -1
- package/dist/cmp/ReadmeFeatures.d.ts +2 -0
- package/dist/cmp/ReadmeFeatures.js +95 -0
- package/dist/cmp/ReadmeFeatures.js.map +1 -0
- package/dist/cmp/ReadmeRef.js +4 -0
- package/dist/cmp/ReadmeRef.js.map +1 -1
- package/dist/cmp/ReadmeRefFeatures.d.ts +2 -0
- package/dist/cmp/ReadmeRefFeatures.js +130 -0
- package/dist/cmp/ReadmeRefFeatures.js.map +1 -0
- package/dist/cmp/ReadmeTop.js +46 -0
- package/dist/cmp/ReadmeTop.js.map +1 -1
- package/dist/helpers/opShape.js +5 -1
- package/dist/helpers/opShape.js.map +1 -1
- package/dist/sdkgen.d.ts +3 -2
- package/dist/sdkgen.js +30 -10
- package/dist/sdkgen.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utility.d.ts +2 -1
- package/dist/utility.js +17 -1
- package/dist/utility.js.map +1 -1
- package/package.json +1 -1
- package/project/.sdk/src/cmp/c/ReadmeRef_c.ts +5 -0
- package/project/.sdk/src/cmp/clojure/ReadmeRef_clojure.ts +5 -0
- package/project/.sdk/src/cmp/cpp/ReadmeRef_cpp.ts +5 -0
- package/project/.sdk/src/cmp/csharp/ReadmeRef_csharp.ts +5 -0
- package/project/.sdk/src/cmp/dart/ReadmeRef_dart.ts +5 -0
- package/project/.sdk/src/cmp/elixir/ReadmeRef_elixir.ts +5 -0
- package/project/.sdk/src/cmp/go/ReadmeRef_go.ts +5 -0
- package/project/.sdk/src/cmp/go/TestDirect_go.ts +123 -2
- package/project/.sdk/src/cmp/java/ReadmeRef_java.ts +5 -0
- package/project/.sdk/src/cmp/js/ReadmeRef_js.ts +5 -0
- package/project/.sdk/src/cmp/js/TestDirect_js.ts +62 -0
- package/project/.sdk/src/cmp/kotlin/ReadmeRef_kotlin.ts +5 -0
- package/project/.sdk/src/cmp/lean/ReadmeRef_lean.ts +27 -3
- package/project/.sdk/src/cmp/lua/ReadmeRef_lua.ts +5 -0
- package/project/.sdk/src/cmp/ocaml/ReadmeRef_ocaml.ts +5 -0
- package/project/.sdk/src/cmp/perl/ReadmeRef_perl.ts +5 -0
- package/project/.sdk/src/cmp/php/ReadmeRef_php.ts +5 -0
- package/project/.sdk/src/cmp/py/ReadmeRef_py.ts +5 -0
- package/project/.sdk/src/cmp/rb/ReadmeRef_rb.ts +5 -0
- package/project/.sdk/src/cmp/rust/ReadmeRef_rust.ts +5 -0
- package/project/.sdk/src/cmp/scala/ReadmeRef_scala.ts +5 -0
- package/project/.sdk/src/cmp/seneca-provider/Extras_seneca-provider.ts +19 -1
- package/project/.sdk/src/cmp/seneca-provider/Main_seneca-provider.ts +27 -2
- package/project/.sdk/src/cmp/swift/ReadmeRef_swift.ts +5 -0
- package/project/.sdk/src/cmp/ts/Config_ts.ts +4 -1
- package/project/.sdk/src/cmp/ts/ReadmeHowto_ts.ts +9 -4
- package/project/.sdk/src/cmp/ts/ReadmeModel_ts.ts +8 -3
- package/project/.sdk/src/cmp/ts/ReadmeQuick_ts.ts +3 -2
- package/project/.sdk/src/cmp/ts/ReadmeRef_ts.ts +7 -2
- package/project/.sdk/src/cmp/ts/ReadmeTopQuick_ts.ts +3 -2
- package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +5 -2
- package/project/.sdk/src/cmp/ts/TestEntity_ts.ts +5 -2
- package/project/.sdk/src/cmp/zig/ReadmeRef_zig.ts +5 -0
- package/project/.sdk/tm/clojure/src/sdk/features.clj +175 -0
- package/project/.sdk/tm/dart/test/feature_test.dart +54 -0
- package/project/.sdk/tm/go/feature/test_feature.go +17 -6
- package/project/.sdk/tm/go/test/feature_corpus_test.go +10 -3
- package/project/.sdk/tm/java/test/FeatureCorpusTest.java +5 -4
- package/project/.sdk/tm/js/src/feature/test/TestFeature.js +8 -1
- package/project/.sdk/tm/js/src/utility/MakeOptionsUtility.js +3 -1
- package/project/.sdk/tm/js/test/feature/Corpus.test.js +13 -3
- package/project/.sdk/tm/js/test/feature.test.js +19 -9
- package/project/.sdk/tm/lean/src/SdkFeatures.lean +186 -1
- package/project/.sdk/tm/lua/feature/test_feature.lua +21 -7
- package/project/.sdk/tm/lua/test/feature_test.lua +18 -18
- package/project/.sdk/tm/lua/utility/make_options.lua +2 -0
- package/project/.sdk/tm/ocaml/sdk_features.ml +5 -0
- package/project/.sdk/tm/perl/t/feature_corpus.t +5 -4
- package/project/.sdk/tm/php/feature/TestFeature.php +19 -8
- package/project/.sdk/tm/php/test/FeatureCorpusTest.php +7 -4
- package/project/.sdk/tm/php/utility/MakeOptions.php +2 -1
- package/project/.sdk/tm/py/pkg/feature/test_feature.py +15 -4
- package/project/.sdk/tm/py/pkg/utility/make_options.py +2 -0
- package/project/.sdk/tm/py/test/test_feature.py +40 -0
- package/project/.sdk/tm/py/test/test_feature_corpus.py +14 -3
- package/project/.sdk/tm/rb/test/feature_corpus_test.rb +12 -4
- package/project/.sdk/tm/ts/src/feature/test/TestFeature.ts +15 -1
- package/project/.sdk/tm/ts/src/utility/MakeOptionsUtility.ts +3 -1
- package/project/.sdk/tm/ts/src/utility/PrepareAuthUtility.ts +20 -0
- package/project/.sdk/tm/ts/test/feature/Corpus.test.ts +13 -3
- package/project/.sdk/tm/ts/test/feature.test.ts +19 -9
- package/project/sdkgen-package.json +1 -1
- package/src/cmp/FeatureDocs.ts +100 -0
- package/src/cmp/Readme.ts +2 -0
- package/src/cmp/ReadmeFeatures.ts +113 -0
- package/src/cmp/ReadmeRef.ts +4 -0
- package/src/cmp/ReadmeRefFeatures.ts +149 -0
- package/src/cmp/ReadmeTop.ts +48 -0
- package/src/helpers/opShape.ts +5 -1
- package/src/sdkgen.ts +28 -7
- package/src/utility.ts +21 -1
|
@@ -13,7 +13,7 @@ import (
|
|
|
13
13
|
|
|
14
14
|
// The `body.<key>` form of an op's response transform: the mock wraps its
|
|
15
15
|
// payload in <key> so the transform can unwrap it again.
|
|
16
|
-
var envelopeResRe = regexp.MustCompile("^`body\\.(
|
|
16
|
+
var envelopeResRe = regexp.MustCompile("^`body\\.(.+)`$")
|
|
17
17
|
|
|
18
18
|
type TestFeature struct {
|
|
19
19
|
BaseFeature
|
|
@@ -78,7 +78,14 @@ func (f *TestFeature) Init(ctx *core.Context, options map[string]any) {
|
|
|
78
78
|
if m == nil {
|
|
79
79
|
return data
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
// Multi-segment on purpose: GraphQL ops unwrap body.data.<field>
|
|
82
|
+
// (and body.data.<field>.<entity> for mutations), not just one level.
|
|
83
|
+
segs := strings.Split(m[1], ".")
|
|
84
|
+
out := data
|
|
85
|
+
for i := len(segs) - 1; 0 <= i; i-- {
|
|
86
|
+
out = map[string]any{segs[i]: out}
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
respond := func(status int, data any, extra map[string]any) map[string]any {
|
|
@@ -346,10 +353,14 @@ func (f *TestFeature) buildArgs(ctx *core.Context, op *core.Operation, args map[
|
|
|
346
353
|
}
|
|
347
354
|
}
|
|
348
355
|
|
|
349
|
-
//
|
|
356
|
+
// Path AND query: a path-only read misses a query-addressed record
|
|
357
|
+
// (e.g. GET /result?trace_id=), which has no path param at all.
|
|
350
358
|
paramsPath := vs.GetPath([]any{"args", "params"}, point)
|
|
351
359
|
reqdParams := vs.Select(paramsPath, map[string]any{"reqd": true})
|
|
352
|
-
|
|
360
|
+
reqdFromParams := vs.Transform(reqdParams, []any{"`$EACH`", "", "`$KEY.name`"})
|
|
361
|
+
queryPath := vs.GetPath([]any{"args", "query"}, point)
|
|
362
|
+
reqdQuery := vs.Select(queryPath, map[string]any{"reqd": true})
|
|
363
|
+
reqdFromQuery := vs.Transform(reqdQuery, []any{"`$EACH`", "", "`$KEY.name`"})
|
|
353
364
|
|
|
354
365
|
qand := []any{}
|
|
355
366
|
q := map[string]any{"`$AND`": &qand}
|
|
@@ -357,8 +368,8 @@ func (f *TestFeature) buildArgs(ctx *core.Context, op *core.Operation, args map[
|
|
|
357
368
|
if args != nil {
|
|
358
369
|
for _, key := range vs.KeysOf(args) {
|
|
359
370
|
isId := key == "id"
|
|
360
|
-
|
|
361
|
-
|
|
371
|
+
isReqd := !vs.IsEmpty(vs.Select(reqdFromParams, key)) ||
|
|
372
|
+
!vs.IsEmpty(vs.Select(reqdFromQuery, key))
|
|
362
373
|
|
|
363
374
|
if isId || isReqd {
|
|
364
375
|
v := ctx.Utility.Param(ctx, key)
|
|
@@ -27,9 +27,10 @@ import (
|
|
|
27
27
|
sdk "GOMODULE"
|
|
28
28
|
)
|
|
29
29
|
|
|
30
|
-
// Features with a corpus section
|
|
31
|
-
// a
|
|
32
|
-
|
|
30
|
+
// Features with a corpus section are read from the corpus itself (see
|
|
31
|
+
// TestFeatureCorpus), so a project-authored section — a custom feature
|
|
32
|
+
// added under .sdk/test/feature/ — runs without editing this file. An
|
|
33
|
+
// SDK generated without a listed feature still skips, not fails.
|
|
33
34
|
|
|
34
35
|
// The standard operation names, in the order the runner prefers them. Every
|
|
35
36
|
// entity declares every CRUD method, so an op that the API does not define
|
|
@@ -450,6 +451,12 @@ func TestFeatureCorpus(t *testing.T) {
|
|
|
450
451
|
"corpus cannot exercise a feature without one")
|
|
451
452
|
}
|
|
452
453
|
|
|
454
|
+
featureCorpusNames := make([]string, 0, len(featureSection))
|
|
455
|
+
for name := range featureSection {
|
|
456
|
+
featureCorpusNames = append(featureCorpusNames, name)
|
|
457
|
+
}
|
|
458
|
+
sort.Strings(featureCorpusNames)
|
|
459
|
+
|
|
453
460
|
for _, name := range featureCorpusNames {
|
|
454
461
|
name := name
|
|
455
462
|
t.Run(name, func(t *testing.T) {
|
|
@@ -43,9 +43,10 @@ import JAVAPACKAGE.core.Utility;
|
|
|
43
43
|
@SuppressWarnings({"unchecked"})
|
|
44
44
|
public class FeatureCorpusTest {
|
|
45
45
|
|
|
46
|
-
// Features with a corpus section
|
|
47
|
-
//
|
|
48
|
-
|
|
46
|
+
// Features with a corpus section are read from the corpus itself (see
|
|
47
|
+
// featureCorpus), so a project-authored section - a custom feature added
|
|
48
|
+
// under .sdk/test/feature/ - runs without editing this file. An SDK
|
|
49
|
+
// generated without a listed feature still skips, not fails.
|
|
49
50
|
|
|
50
51
|
// The standard operation names, in the order the runner prefers them.
|
|
51
52
|
private static final List<String> FEATURE_CORPUS_OPS =
|
|
@@ -377,7 +378,7 @@ public class FeatureCorpusTest {
|
|
|
377
378
|
"this project's test.json has no `feature` section - recompile the "
|
|
378
379
|
+ "corpus (create-sdkgen .sdk/test/feature/) to run these cases");
|
|
379
380
|
|
|
380
|
-
for (String name :
|
|
381
|
+
for (String name : new TreeMap<>(features).keySet()) {
|
|
381
382
|
Object sectionRaw = features.get(name);
|
|
382
383
|
if (!(sectionRaw instanceof Map)) {
|
|
383
384
|
continue;
|
|
@@ -274,10 +274,17 @@ class TestFeature extends BaseFeature {
|
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
|
|
277
|
+
// Path AND query: a path-only read misses a query-addressed record
|
|
278
|
+
// (e.g. GET /result?trace_id=), which has no path param at all.
|
|
279
|
+
const reqdParams = transform(
|
|
278
280
|
select(getpath(point, ['args', 'params']), { reqd: true }),
|
|
279
281
|
['`$EACH`', '', '`$KEY.name`']
|
|
280
282
|
)
|
|
283
|
+
const reqdQuery = transform(
|
|
284
|
+
select(getpath(point, ['args', 'query']), { reqd: true }),
|
|
285
|
+
['`$EACH`', '', '`$KEY.name`']
|
|
286
|
+
)
|
|
287
|
+
const reqd = [...(reqdParams || []), ...(reqdQuery || [])]
|
|
281
288
|
|
|
282
289
|
const qand = []
|
|
283
290
|
const q = { '`$AND`': qand }
|
|
@@ -41,11 +41,13 @@ function makeOptions(ctx) {
|
|
|
41
41
|
// Standard SDK option values.
|
|
42
42
|
const optspec = {
|
|
43
43
|
apikey: '',
|
|
44
|
+
secret: '',
|
|
44
45
|
base: 'http://localhost:8000',
|
|
45
46
|
prefix: '',
|
|
46
47
|
suffix: '',
|
|
47
48
|
auth: {
|
|
48
|
-
prefix: ''
|
|
49
|
+
prefix: '',
|
|
50
|
+
basic: false
|
|
49
51
|
},
|
|
50
52
|
headers: {
|
|
51
53
|
'`$CHILD`': '`$STRING`'
|
|
@@ -27,9 +27,19 @@ const { join } = require('node:path')
|
|
|
27
27
|
const { SDK, TEST_JSON_FILE } = require('../utility/index')
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
// Features with a corpus section
|
|
31
|
-
//
|
|
32
|
-
|
|
30
|
+
// Features with a corpus section — read from the corpus itself, so a
|
|
31
|
+
// project-authored section (a custom feature added under
|
|
32
|
+
// .sdk/test/feature/) runs without editing this file. Read eagerly: the
|
|
33
|
+
// node test runner collects the per-feature tests at describe time,
|
|
34
|
+
// before any `before` hook fires. An SDK generated without a listed
|
|
35
|
+
// feature still skips, not fails.
|
|
36
|
+
const FEATURES = Object.keys((() => {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(
|
|
39
|
+
join(__dirname, '..', TEST_JSON_FILE), 'utf8')).feature || {}
|
|
40
|
+
}
|
|
41
|
+
catch (e) { return {} }
|
|
42
|
+
})()).sort()
|
|
33
43
|
|
|
34
44
|
|
|
35
45
|
// One operation this SDK can actually perform is described by
|
|
@@ -21,6 +21,16 @@ function recordingServer(reply) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
// A subtest that drives a second feature (netsim as the simulated network)
|
|
25
|
+
// can only run when this SDK was generated with it: the harness skips
|
|
26
|
+
// absent features, which would leave the scenario unsimulated and the
|
|
27
|
+
// assertions meaningless. Same convention as the other targets.
|
|
28
|
+
function skipWithout(name) {
|
|
29
|
+
return hasFeature(name) ? false
|
|
30
|
+
: ('this SDK was generated without the ' + name + ' feature')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
24
34
|
describe('feature', () => {
|
|
25
35
|
|
|
26
36
|
test('at least the test feature is present', () => {
|
|
@@ -95,7 +105,7 @@ describe('feature', () => {
|
|
|
95
105
|
// --- retry ----------------------------------------------------------------
|
|
96
106
|
if (hasFeature('retry')) describe('retry', () => {
|
|
97
107
|
|
|
98
|
-
test('retries transient failures then succeeds', async () => {
|
|
108
|
+
test('retries transient failures then succeeds', { skip: skipWithout('netsim') }, async () => {
|
|
99
109
|
const clock = makeClock()
|
|
100
110
|
const h = makeClient({ features: [
|
|
101
111
|
{ name: 'netsim', options: { failTimes: 2, failStatus: 503 } },
|
|
@@ -105,7 +115,7 @@ describe('feature', () => {
|
|
|
105
115
|
strictEqual(h.client._retry.attempts, 2)
|
|
106
116
|
})
|
|
107
117
|
|
|
108
|
-
test('gives up after the budget', async () => {
|
|
118
|
+
test('gives up after the budget', { skip: skipWithout('netsim') }, async () => {
|
|
109
119
|
const clock = makeClock()
|
|
110
120
|
const h = makeClient({ features: [
|
|
111
121
|
{ name: 'netsim', options: { failTimes: 9, failStatus: 500 } },
|
|
@@ -131,7 +141,7 @@ describe('feature', () => {
|
|
|
131
141
|
strictEqual(n, 3)
|
|
132
142
|
})
|
|
133
143
|
|
|
134
|
-
test('honours a server Retry-After', async () => {
|
|
144
|
+
test('honours a server Retry-After', { skip: skipWithout('netsim') }, async () => {
|
|
135
145
|
const clock = makeClock()
|
|
136
146
|
const h = makeClient({ features: [
|
|
137
147
|
{ name: 'netsim', options: { rateLimitTimes: 1, retryAfter: 2 } },
|
|
@@ -141,7 +151,7 @@ describe('feature', () => {
|
|
|
141
151
|
strictEqual(clock.time, 2000)
|
|
142
152
|
})
|
|
143
153
|
|
|
144
|
-
test('default jitter path still succeeds', async () => {
|
|
154
|
+
test('default jitter path still succeeds', { skip: skipWithout('netsim') }, async () => {
|
|
145
155
|
const h = makeClient({ features: [
|
|
146
156
|
{ name: 'netsim', options: { failTimes: 1 } },
|
|
147
157
|
{ name: 'retry', options: { retries: 2, minDelay: 0 } },
|
|
@@ -154,7 +164,7 @@ describe('feature', () => {
|
|
|
154
164
|
// --- timeout --------------------------------------------------------------
|
|
155
165
|
if (hasFeature('timeout')) describe('timeout', () => {
|
|
156
166
|
|
|
157
|
-
test('a slow request times out', async () => {
|
|
167
|
+
test('a slow request times out', { skip: skipWithout('netsim') }, async () => {
|
|
158
168
|
const h = makeClient({ features: [
|
|
159
169
|
{ name: 'netsim', options: { latency: 80 } },
|
|
160
170
|
{ name: 'timeout', options: { ms: 10 } },
|
|
@@ -319,7 +329,7 @@ describe('feature', () => {
|
|
|
319
329
|
// --- metrics --------------------------------------------------------------
|
|
320
330
|
if (hasFeature('metrics')) describe('metrics', () => {
|
|
321
331
|
|
|
322
|
-
test('counts ok and err per op', async () => {
|
|
332
|
+
test('counts ok and err per op', { skip: skipWithout('netsim') }, async () => {
|
|
323
333
|
const h = makeClient({ features: [
|
|
324
334
|
{ name: 'netsim', options: { failTimes: 1, failStatus: 500 } },
|
|
325
335
|
{ name: 'metrics', options: {} },
|
|
@@ -352,7 +362,7 @@ describe('feature', () => {
|
|
|
352
362
|
ok(/^00-.+-.+-01$/.test(sent['traceparent']))
|
|
353
363
|
})
|
|
354
364
|
|
|
355
|
-
test('records a failed span on error', async () => {
|
|
365
|
+
test('records a failed span on error', { skip: skipWithout('netsim') }, async () => {
|
|
356
366
|
const h = makeClient({ features: [
|
|
357
367
|
{ name: 'netsim', options: { failTimes: 1, failStatus: 500 } },
|
|
358
368
|
{ name: 'telemetry', options: {} },
|
|
@@ -377,7 +387,7 @@ describe('feature', () => {
|
|
|
377
387
|
strictEqual(seen[0].headers.authorization, '<redacted>')
|
|
378
388
|
})
|
|
379
389
|
|
|
380
|
-
test('captures failures', async () => {
|
|
390
|
+
test('captures failures', { skip: skipWithout('netsim') }, async () => {
|
|
381
391
|
const h = makeClient({ features: [
|
|
382
392
|
{ name: 'netsim', options: { failTimes: 1, failStatus: 500 } },
|
|
383
393
|
{ name: 'debug', options: {} },
|
|
@@ -391,7 +401,7 @@ describe('feature', () => {
|
|
|
391
401
|
// --- audit ----------------------------------------------------------------
|
|
392
402
|
if (hasFeature('audit')) describe('audit', () => {
|
|
393
403
|
|
|
394
|
-
test('one record per op with sink + actor', async () => {
|
|
404
|
+
test('one record per op with sink + actor', { skip: skipWithout('netsim') }, async () => {
|
|
395
405
|
const sink = []
|
|
396
406
|
const h = makeClient({ features: [
|
|
397
407
|
{ name: 'netsim', options: { failTimes: 1, failStatus: 500 } },
|
|
@@ -479,6 +479,190 @@ def pagingFeature : SIO Feature := do
|
|
|
479
479
|
(newMap #[("pages", .num 0.0), ("items", .num 0.0)])
|
|
480
480
|
bumpNum b "items" n.toFloat }
|
|
481
481
|
|
|
482
|
+
-- ---------------------------------------------------------------------------
|
|
483
|
+
-- cost
|
|
484
|
+
-- ---------------------------------------------------------------------------
|
|
485
|
+
-- Prices every transport ATTEMPT and commits the spend once per OPERATION.
|
|
486
|
+
-- Mirrors tm/ts/src/feature/cost/CostFeature.ts.
|
|
487
|
+
--
|
|
488
|
+
-- ORDER MATTERS. Cost must sit INSIDE the cache, or a response served from
|
|
489
|
+
-- cache is charged for money that was never spent.
|
|
490
|
+
|
|
491
|
+
def costFeature : SIO Feature := do
|
|
492
|
+
let optsR ← IO.mkRef (← emptyMap)
|
|
493
|
+
-- Accumulated per call and committed once: adding each attempt to the
|
|
494
|
+
-- running total and subtracting it again loses precision.
|
|
495
|
+
let pendR ← IO.mkRef (← newMap #[("attempts", Value.num 0.0), ("amount", Value.num 0.0),
|
|
496
|
+
("reported", Value.num 0.0), ("estimated", Value.num 0.0),
|
|
497
|
+
("source", Value.str "none")])
|
|
498
|
+
let seqR ← IO.mkRef 0
|
|
499
|
+
|
|
500
|
+
let record : Value → SIO Value := fun ctx => do
|
|
501
|
+
let o ← optsR.get
|
|
502
|
+
let cur ← optStr o "currency" "USD"
|
|
503
|
+
let lim ← optNum o "budget" 0.0
|
|
504
|
+
let total ← newMap #[("calls", Value.num 0.0), ("attempts", Value.num 0.0),
|
|
505
|
+
("amount", Value.num 0.0), ("reported", Value.num 0.0),
|
|
506
|
+
("estimated", Value.num 0.0)]
|
|
507
|
+
let ops ← emptyMap
|
|
508
|
+
let actors ← emptyMap
|
|
509
|
+
let budget ← newMap #[("limit", Value.num lim), ("spent", Value.num 0.0),
|
|
510
|
+
("remaining", Value.num lim), ("exceeded", Value.bool false)]
|
|
511
|
+
trackCtx ctx "cost" (newMap #[("currency", Value.str cur), ("total", total),
|
|
512
|
+
("ops", ops), ("actors", actors), ("budget", budget),
|
|
513
|
+
("last", Value.noval)])
|
|
514
|
+
|
|
515
|
+
let resetPending : SIO Unit := do
|
|
516
|
+
let p ← newMap #[("attempts", Value.num 0.0), ("amount", Value.num 0.0),
|
|
517
|
+
("reported", Value.num 0.0), ("estimated", Value.num 0.0),
|
|
518
|
+
("source", Value.str "none")]
|
|
519
|
+
pendR.set p
|
|
520
|
+
|
|
521
|
+
-- Pricing precedence: a server-stated header beats the rate table, which
|
|
522
|
+
-- beats the flat unit.
|
|
523
|
+
let priceOf : Value → Value → SIO (Float × String) := fun ctx res => do
|
|
524
|
+
let o ← optsR.get
|
|
525
|
+
let perUnit ← optNum o "perUnit" 0.0
|
|
526
|
+
let hname ← optStr o "header" ""
|
|
527
|
+
let hv ← if hname == "" then pure Value.noval else headerCI (← gp res "headers") hname
|
|
528
|
+
let hn := numOf hv (-1.0)
|
|
529
|
+
if hname != "" && hn >= 0.0 then
|
|
530
|
+
pure (hn * perUnit, "header")
|
|
531
|
+
else do
|
|
532
|
+
let rates ← gp o "rates"
|
|
533
|
+
let opname ← SdkUtility.opnameOf ctx
|
|
534
|
+
-- The documented grammar is '<entity>.<op>', then '<op>', then '*'.
|
|
535
|
+
-- Checking only the bare name charged `{'widget.load': 0.25, '*': 0.01}`
|
|
536
|
+
-- a widget load at 0.01, unlike every other port.
|
|
537
|
+
let ent ← gpS (← gp ctx "op") "entity"
|
|
538
|
+
let byEntOp := numOf (← gp rates (ent ++ "." ++ opname)) (-1.0)
|
|
539
|
+
let byOp := numOf (← gp rates opname) (-1.0)
|
|
540
|
+
let byAny := numOf (← gp rates "*") (-1.0)
|
|
541
|
+
if byEntOp >= 0.0 then pure (byEntOp, "table")
|
|
542
|
+
else if byOp >= 0.0 then pure (byOp, "table")
|
|
543
|
+
else if byAny >= 0.0 then pure (byAny, "table")
|
|
544
|
+
else do
|
|
545
|
+
let unit ← optNum o "unit" 0.0
|
|
546
|
+
if unit != 0.0 then pure (unit, "unit") else pure (0.0, "none")
|
|
547
|
+
|
|
548
|
+
let bucketOf : Value → String → SIO Value := fun bucket key => do
|
|
549
|
+
let b ← gp bucket key
|
|
550
|
+
match b with
|
|
551
|
+
| Value.map _ => pure b
|
|
552
|
+
| _ => do
|
|
553
|
+
let m ← newMap #[("calls", Value.num 0.0), ("amount", Value.num 0.0)]
|
|
554
|
+
sp bucket key m
|
|
555
|
+
pure m
|
|
556
|
+
|
|
557
|
+
let commit : Value → SIO Unit := fun ctx => do
|
|
558
|
+
let o ← optsR.get
|
|
559
|
+
let p ← pendR.get
|
|
560
|
+
let rec_ ← record ctx
|
|
561
|
+
|
|
562
|
+
-- A body usage figure prices the WHOLE call, so it replaces the
|
|
563
|
+
-- per-attempt estimate rather than adding to it - and, being
|
|
564
|
+
-- server-stated, the whole amount counts as reported. Read here, not at
|
|
565
|
+
-- the transport seam, because the body is one-shot.
|
|
566
|
+
let path ← optStr o "path" ""
|
|
567
|
+
let perUnit ← optNum o "perUnit" 0.0
|
|
568
|
+
let bodyAmt ← if path == "" then pure (-1.0) else do
|
|
569
|
+
let body ← gp (← gp ctx "result") "body"
|
|
570
|
+
pure (numOf (← getpath body (Value.str path)) (-1.0))
|
|
571
|
+
|
|
572
|
+
-- Bind the pending figures first: `←` cannot be nested inside an
|
|
573
|
+
-- if-then-else that is not itself a `do` block.
|
|
574
|
+
let pendAmount := numOf (← gp p "amount") 0.0
|
|
575
|
+
let pendReported := numOf (← gp p "reported") 0.0
|
|
576
|
+
let pendEstimated := numOf (← gp p "estimated") 0.0
|
|
577
|
+
let amount := if bodyAmt >= 0.0 then bodyAmt * perUnit else pendAmount
|
|
578
|
+
let reported := if bodyAmt >= 0.0 then amount else pendReported
|
|
579
|
+
let estimated := if bodyAmt >= 0.0 then 0.0 else pendEstimated
|
|
580
|
+
if bodyAmt >= 0.0 then sp p "source" (Value.str "body")
|
|
581
|
+
|
|
582
|
+
let total ← gp rec_ "total"
|
|
583
|
+
bumpNum total "amount" amount
|
|
584
|
+
bumpNum total "reported" reported
|
|
585
|
+
bumpNum total "estimated" estimated
|
|
586
|
+
bumpNum total "calls" 1.0
|
|
587
|
+
|
|
588
|
+
let budget ← gp rec_ "budget"
|
|
589
|
+
let lim := numOf (← gp budget "limit") 0.0
|
|
590
|
+
let spent := numOf (← gp total "amount") 0.0
|
|
591
|
+
sp budget "spent" (Value.num spent)
|
|
592
|
+
sp budget "remaining"
|
|
593
|
+
(Value.num (if lim > 0.0 then (if lim - spent > 0.0 then lim - spent else 0.0) else 0.0))
|
|
594
|
+
if lim > 0.0 && spent >= lim then sp budget "exceeded" (Value.bool true)
|
|
595
|
+
|
|
596
|
+
let opname ← SdkUtility.opnameOf ctx
|
|
597
|
+
let actorV ← gp (← gp ctx "ctrl") "actor"
|
|
598
|
+
let actorS := match actorV with | Value.str a => a | _ => ""
|
|
599
|
+
let actor ← if actorS != "" then pure actorS else optStr o "actor" "anonymous"
|
|
600
|
+
|
|
601
|
+
let opB ← bucketOf (← gp rec_ "ops") ("_." ++ opname)
|
|
602
|
+
bumpNum opB "calls" 1.0
|
|
603
|
+
bumpNum opB "amount" amount
|
|
604
|
+
let acB ← bucketOf (← gp rec_ "actors") actor
|
|
605
|
+
bumpNum acB "calls" 1.0
|
|
606
|
+
bumpNum acB "amount" amount
|
|
607
|
+
|
|
608
|
+
let n ← seqR.modifyGet fun n => (n + 1, n + 1)
|
|
609
|
+
let last ← newMap #[("seq", Value.num n.toFloat), ("op", Value.str opname),
|
|
610
|
+
("actor", Value.str actor), ("amount", Value.num amount),
|
|
611
|
+
("currency", ← gp rec_ "currency"), ("source", ← gp p "source"),
|
|
612
|
+
("attempts", ← gp p "attempts")]
|
|
613
|
+
sp rec_ "last" last
|
|
614
|
+
resetPending
|
|
615
|
+
|
|
616
|
+
pure { name := "cost"
|
|
617
|
+
, init := fun ctx opts => do
|
|
618
|
+
let om ← toOptsMap opts
|
|
619
|
+
optsR.set om
|
|
620
|
+
resetPending
|
|
621
|
+
if (← optActive opts) then do
|
|
622
|
+
let _ ← record ctx
|
|
623
|
+
let client ← clientOf ctx
|
|
624
|
+
let inner ← getFetcher client
|
|
625
|
+
setFetcher client fun c u f => do
|
|
626
|
+
-- The transport answers (response, error): a rejecting attempt
|
|
627
|
+
-- still costs, or a run of failures under retry would be free.
|
|
628
|
+
let (res, rerr) ← inner c u f
|
|
629
|
+
let (amount, source) ← priceOf c res
|
|
630
|
+
let p ← pendR.get
|
|
631
|
+
bumpNum p "attempts" 1.0
|
|
632
|
+
bumpNum p "amount" amount
|
|
633
|
+
bumpNum p (if source == "header" then "reported" else "estimated") amount
|
|
634
|
+
sp p "source" (Value.str source)
|
|
635
|
+
let rec_ ← record c
|
|
636
|
+
bumpNum (← gp rec_ "total") "attempts" 1.0
|
|
637
|
+
pure (res, rerr)
|
|
638
|
+
, hook := fun stage ctx => do
|
|
639
|
+
let o ← optsR.get
|
|
640
|
+
if stage == "PrePoint" then do
|
|
641
|
+
let lim ← optNum o "budget" 0.0
|
|
642
|
+
if lim > 0.0 then do
|
|
643
|
+
let rec_ ← record ctx
|
|
644
|
+
let spent := numOf (← gp (← gp rec_ "total") "amount") 0.0
|
|
645
|
+
if spent >= lim then do
|
|
646
|
+
sp (← gp rec_ "budget") "exceeded" (Value.bool true)
|
|
647
|
+
if (← optStr o "onBudget" "warn") == "deny" then do
|
|
648
|
+
let e ← SdkUtility.mkErr "cost_budget"
|
|
649
|
+
("Cost budget of " ++ toString lim ++ " is spent")
|
|
650
|
+
let out ← SdkUtility.gpMap ctx "out"
|
|
651
|
+
sp out "point" e
|
|
652
|
+
else if stage == "PreDone" then do
|
|
653
|
+
-- A cache hit is a real call that cost nothing, so it commits
|
|
654
|
+
-- too: that is the point of ordering cost inside the cache.
|
|
655
|
+
commit ctx
|
|
656
|
+
else if stage == "PreUnexpected" then do
|
|
657
|
+
-- A failed operation never reaches PreDone, so without this its
|
|
658
|
+
-- attempts are priced and then discarded: repeated connection
|
|
659
|
+
-- failures would slip past an onBudget "deny" ceiling, and the
|
|
660
|
+
-- shared pending value would survive to be attributed to the
|
|
661
|
+
-- next successful call. A call that made NO attempt was refused
|
|
662
|
+
-- before the network and must not be counted.
|
|
663
|
+
let p ← pendR.get
|
|
664
|
+
if numOf (← gp p "attempts") 0.0 > 0.0 then commit ctx else resetPending }
|
|
665
|
+
|
|
482
666
|
def streamingFeature : SIO Feature := do
|
|
483
667
|
let optsR ← IO.mkRef (← emptyMap)
|
|
484
668
|
pure { name := "streaming"
|
|
@@ -591,12 +775,13 @@ def makeFeature (name : String) : SIO Feature :=
|
|
|
591
775
|
| "streaming" => streamingFeature
|
|
592
776
|
| "proxy" => proxyFeature
|
|
593
777
|
| "netsim" => netsimFeature
|
|
778
|
+
| "cost" => costFeature
|
|
594
779
|
| _ => baseFeature
|
|
595
780
|
|
|
596
781
|
/-- The catalog order: transport wrappers compose in this order. -/
|
|
597
782
|
def featureNames : Array String :=
|
|
598
783
|
#["log", "rbac", "idempotency", "clienttrack", "paging", "streaming",
|
|
599
784
|
"metrics", "telemetry", "debug", "audit",
|
|
600
|
-
"cache", "ratelimit", "timeout", "retry", "proxy", "netsim"]
|
|
785
|
+
"cost", "cache", "ratelimit", "timeout", "retry", "proxy", "netsim"]
|
|
601
786
|
|
|
602
787
|
end SdkFeatures
|
|
@@ -61,11 +61,21 @@ function TestFeature:init(ctx, options)
|
|
|
61
61
|
if type(restf) ~= "string" then
|
|
62
62
|
return data
|
|
63
63
|
end
|
|
64
|
-
local
|
|
65
|
-
if
|
|
64
|
+
local path = string.match(restf, "^`body%.(.+)`$")
|
|
65
|
+
if path == nil then
|
|
66
66
|
return data
|
|
67
67
|
end
|
|
68
|
-
|
|
68
|
+
-- Multi-segment on purpose: GraphQL ops unwrap body.data.<field> (and
|
|
69
|
+
-- body.data.<field>.<entity> for mutations), not just one level.
|
|
70
|
+
local segs = {}
|
|
71
|
+
for seg in string.gmatch(path, "[^.]+") do
|
|
72
|
+
table.insert(segs, seg)
|
|
73
|
+
end
|
|
74
|
+
local out = data
|
|
75
|
+
for i = #segs, 1, -1 do
|
|
76
|
+
out = { [segs[i]] = out }
|
|
77
|
+
end
|
|
78
|
+
return out
|
|
69
79
|
end
|
|
70
80
|
|
|
71
81
|
local function respond(status, data, extra)
|
|
@@ -309,10 +319,14 @@ function TestFeature:build_args(ctx, op, args)
|
|
|
309
319
|
local points = vs.getpath(ctx.config, "entity." .. ctx.entity:get_name() .. ".op." .. opname .. ".points")
|
|
310
320
|
local point = vs.getelem(points, -1)
|
|
311
321
|
|
|
312
|
-
--
|
|
322
|
+
-- Path AND query: a path-only read misses a query-addressed record
|
|
323
|
+
-- (e.g. GET /result?trace_id=), which has no path param at all.
|
|
313
324
|
local params_path = vs.getpath(point, "args.params")
|
|
314
325
|
local reqd_params = vs.select(params_path, { reqd = true })
|
|
315
|
-
local
|
|
326
|
+
local reqd_from_params = vs.transform(reqd_params, { "`$EACH`", "", "`$KEY.name`" })
|
|
327
|
+
local query_path = vs.getpath(point, "args.query")
|
|
328
|
+
local reqd_query = vs.select(query_path, { reqd = true })
|
|
329
|
+
local reqd_from_query = vs.transform(reqd_query, { "`$EACH`", "", "`$KEY.name`" })
|
|
316
330
|
|
|
317
331
|
local qand = {}
|
|
318
332
|
local q = { ["`$AND`"] = qand }
|
|
@@ -322,8 +336,8 @@ function TestFeature:build_args(ctx, op, args)
|
|
|
322
336
|
if keys ~= nil then
|
|
323
337
|
for _, key in ipairs(keys) do
|
|
324
338
|
local is_id = (key == "id")
|
|
325
|
-
local
|
|
326
|
-
|
|
339
|
+
local is_reqd = not vs.isempty(vs.select(reqd_from_params, key))
|
|
340
|
+
or not vs.isempty(vs.select(reqd_from_query, key))
|
|
327
341
|
|
|
328
342
|
if is_id or is_reqd then
|
|
329
343
|
local v = ctx.utility.param(ctx, key)
|
|
@@ -441,7 +441,7 @@ describe("feature", function()
|
|
|
441
441
|
-- retry ---------------------------------------------------------------
|
|
442
442
|
if has_feature("retry") then describe("retry", function()
|
|
443
443
|
|
|
444
|
-
it("retries transient failures then succeeds", function()
|
|
444
|
+
if has_feature("netsim") then it("retries transient failures then succeeds", function()
|
|
445
445
|
local clock = make_clock()
|
|
446
446
|
local h = make_client({ features = {
|
|
447
447
|
{ name = "netsim", options = { failTimes = 2, failStatus = 503 } },
|
|
@@ -450,9 +450,9 @@ describe("feature", function()
|
|
|
450
450
|
} })
|
|
451
451
|
assert.is_true(h.op({ op = "load" }).ok)
|
|
452
452
|
assert.are.equal(2, h.client._retry.attempts)
|
|
453
|
-
end)
|
|
453
|
+
end) end
|
|
454
454
|
|
|
455
|
-
it("gives up after the budget", function()
|
|
455
|
+
if has_feature("netsim") then it("gives up after the budget", function()
|
|
456
456
|
local clock = make_clock()
|
|
457
457
|
local h = make_client({ features = {
|
|
458
458
|
{ name = "netsim", options = { failTimes = 9, failStatus = 500 } },
|
|
@@ -460,7 +460,7 @@ describe("feature", function()
|
|
|
460
460
|
retries = 2, minDelay = 1, jitter = false, sleep = clock.sleep } },
|
|
461
461
|
} })
|
|
462
462
|
assert.are.equal(500, h.op({ op = "load" }).result.status)
|
|
463
|
-
end)
|
|
463
|
+
end) end
|
|
464
464
|
|
|
465
465
|
it("does not retry a non-retryable status", function()
|
|
466
466
|
local rec = recording_server(function() return make_response(404) end)
|
|
@@ -487,7 +487,7 @@ describe("feature", function()
|
|
|
487
487
|
assert.are.equal(3, n)
|
|
488
488
|
end)
|
|
489
489
|
|
|
490
|
-
it("honours a server Retry-After", function()
|
|
490
|
+
if has_feature("netsim") then it("honours a server Retry-After", function()
|
|
491
491
|
local clock = make_clock()
|
|
492
492
|
local h = make_client({ features = {
|
|
493
493
|
{ name = "netsim", options = { rateLimitTimes = 1, retryAfter = 2 } },
|
|
@@ -497,15 +497,15 @@ describe("feature", function()
|
|
|
497
497
|
} })
|
|
498
498
|
assert.is_true(h.op({ op = "load" }).ok)
|
|
499
499
|
assert.are.equal(2000, clock.time())
|
|
500
|
-
end)
|
|
500
|
+
end) end
|
|
501
501
|
|
|
502
|
-
it("default jitter path still succeeds", function()
|
|
502
|
+
if has_feature("netsim") then it("default jitter path still succeeds", function()
|
|
503
503
|
local h = make_client({ features = {
|
|
504
504
|
{ name = "netsim", options = { failTimes = 1 } },
|
|
505
505
|
{ name = "retry", options = { retries = 2, minDelay = 0 } },
|
|
506
506
|
} })
|
|
507
507
|
assert.is_true(h.op({ op = "load" }).ok)
|
|
508
|
-
end)
|
|
508
|
+
end) end
|
|
509
509
|
|
|
510
510
|
it("inactive retry does not wrap", function()
|
|
511
511
|
local rec = recording_server(function() return make_response(503) end)
|
|
@@ -551,7 +551,7 @@ describe("feature", function()
|
|
|
551
551
|
-- shared virtual clock stands in for elapsed transport time.
|
|
552
552
|
if has_feature("timeout") then describe("timeout", function()
|
|
553
553
|
|
|
554
|
-
it("a slow request times out", function()
|
|
554
|
+
if has_feature("netsim") then it("a slow request times out", function()
|
|
555
555
|
local clock = make_clock()
|
|
556
556
|
local h = make_client({ features = {
|
|
557
557
|
{ name = "netsim", options = { latency = 80, sleep = clock.sleep } },
|
|
@@ -560,7 +560,7 @@ describe("feature", function()
|
|
|
560
560
|
local res = h.op({ op = "load" })
|
|
561
561
|
assert.are.equal("timeout", res.error.code)
|
|
562
562
|
assert.are.equal(1, h.client._timeout.count)
|
|
563
|
-
end)
|
|
563
|
+
end) end
|
|
564
564
|
|
|
565
565
|
it("a fast request passes through", function()
|
|
566
566
|
local clock = make_clock()
|
|
@@ -819,7 +819,7 @@ describe("feature", function()
|
|
|
819
819
|
-- metrics ---------------------------------------------------------------
|
|
820
820
|
if has_feature("metrics") then describe("metrics", function()
|
|
821
821
|
|
|
822
|
-
it("counts ok and err per op", function()
|
|
822
|
+
if has_feature("netsim") then it("counts ok and err per op", function()
|
|
823
823
|
local h = make_client({ features = {
|
|
824
824
|
{ name = "netsim", options = { failTimes = 1, failStatus = 500 } },
|
|
825
825
|
{ name = "metrics", options = {} },
|
|
@@ -832,7 +832,7 @@ describe("feature", function()
|
|
|
832
832
|
assert.are.equal(2, m.total.ok)
|
|
833
833
|
assert.are.equal(1, m.total.err)
|
|
834
834
|
assert.are.equal(2, m.ops["widget.load"].count)
|
|
835
|
-
end)
|
|
835
|
+
end) end
|
|
836
836
|
|
|
837
837
|
it("injected clock accumulates durations", function()
|
|
838
838
|
local t = 0
|
|
@@ -872,14 +872,14 @@ describe("feature", function()
|
|
|
872
872
|
assert.is_truthy(string.match(sent["traceparent"], "^00%-.+%-.+%-01$"))
|
|
873
873
|
end)
|
|
874
874
|
|
|
875
|
-
it("records a failed span on error", function()
|
|
875
|
+
if has_feature("netsim") then it("records a failed span on error", function()
|
|
876
876
|
local h = make_client({ features = {
|
|
877
877
|
{ name = "netsim", options = { failTimes = 1, failStatus = 500 } },
|
|
878
878
|
{ name = "telemetry", options = {} },
|
|
879
879
|
} })
|
|
880
880
|
h.op({ op = "load" })
|
|
881
881
|
assert.is_false(h.client._telemetry.spans[1].ok)
|
|
882
|
-
end)
|
|
882
|
+
end) end
|
|
883
883
|
|
|
884
884
|
it("default id generation and no exporter", function()
|
|
885
885
|
local h = make_client({ features = { { name = "telemetry" } } })
|
|
@@ -925,14 +925,14 @@ describe("feature", function()
|
|
|
925
925
|
assert.are.equal("<redacted>", seen[1].headers.authorization)
|
|
926
926
|
end)
|
|
927
927
|
|
|
928
|
-
it("captures failures", function()
|
|
928
|
+
if has_feature("netsim") then it("captures failures", function()
|
|
929
929
|
local h = make_client({ features = {
|
|
930
930
|
{ name = "netsim", options = { failTimes = 1, failStatus = 500 } },
|
|
931
931
|
{ name = "debug", options = {} },
|
|
932
932
|
} })
|
|
933
933
|
h.op({ op = "load" })
|
|
934
934
|
assert.is_false(h.client._debug.entries[1].ok)
|
|
935
|
-
end)
|
|
935
|
+
end) end
|
|
936
936
|
|
|
937
937
|
it("injected clock + custom redact", function()
|
|
938
938
|
local h = make_client({ features = {
|
|
@@ -957,7 +957,7 @@ describe("feature", function()
|
|
|
957
957
|
-- audit -----------------------------------------------------------------
|
|
958
958
|
if has_feature("audit") then describe("audit", function()
|
|
959
959
|
|
|
960
|
-
it("one record per op with sink + actor", function()
|
|
960
|
+
if has_feature("netsim") then it("one record per op with sink + actor", function()
|
|
961
961
|
local sink = {}
|
|
962
962
|
local h = make_client({ features = {
|
|
963
963
|
{ name = "netsim", options = { failTimes = 1, failStatus = 500 } },
|
|
@@ -972,7 +972,7 @@ describe("feature", function()
|
|
|
972
972
|
assert.are.equal("svc", recs[1].actor)
|
|
973
973
|
assert.are.equal("per-call", recs[2].actor)
|
|
974
974
|
assert.are.equal(2, #sink)
|
|
975
|
-
end)
|
|
975
|
+
end) end
|
|
976
976
|
|
|
977
977
|
it("default actor is anonymous", function()
|
|
978
978
|
local h = make_client({ features = { { name = "audit" } } })
|