@voxgig/sdkgen 4.4.0 → 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.
Files changed (100) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/dist/cmp/FeatureDocs.d.ts +16 -0
  3. package/dist/cmp/FeatureDocs.js +72 -0
  4. package/dist/cmp/FeatureDocs.js.map +1 -0
  5. package/dist/cmp/Readme.js +2 -0
  6. package/dist/cmp/Readme.js.map +1 -1
  7. package/dist/cmp/ReadmeFeatures.d.ts +2 -0
  8. package/dist/cmp/ReadmeFeatures.js +95 -0
  9. package/dist/cmp/ReadmeFeatures.js.map +1 -0
  10. package/dist/cmp/ReadmeRef.js +4 -0
  11. package/dist/cmp/ReadmeRef.js.map +1 -1
  12. package/dist/cmp/ReadmeRefFeatures.d.ts +2 -0
  13. package/dist/cmp/ReadmeRefFeatures.js +130 -0
  14. package/dist/cmp/ReadmeRefFeatures.js.map +1 -0
  15. package/dist/cmp/ReadmeTop.js +46 -0
  16. package/dist/cmp/ReadmeTop.js.map +1 -1
  17. package/dist/helpers/opShape.js +5 -1
  18. package/dist/helpers/opShape.js.map +1 -1
  19. package/dist/sdkgen.d.ts +3 -2
  20. package/dist/sdkgen.js +30 -10
  21. package/dist/sdkgen.js.map +1 -1
  22. package/dist/tsconfig.tsbuildinfo +1 -1
  23. package/dist/utility.d.ts +2 -1
  24. package/dist/utility.js +17 -1
  25. package/dist/utility.js.map +1 -1
  26. package/package.json +1 -1
  27. package/project/.sdk/src/cmp/c/ReadmeRef_c.ts +5 -0
  28. package/project/.sdk/src/cmp/clojure/ReadmeRef_clojure.ts +5 -0
  29. package/project/.sdk/src/cmp/cpp/ReadmeRef_cpp.ts +5 -0
  30. package/project/.sdk/src/cmp/csharp/ReadmeRef_csharp.ts +5 -0
  31. package/project/.sdk/src/cmp/dart/ReadmeRef_dart.ts +5 -0
  32. package/project/.sdk/src/cmp/elixir/ReadmeRef_elixir.ts +5 -0
  33. package/project/.sdk/src/cmp/go/ReadmeRef_go.ts +5 -0
  34. package/project/.sdk/src/cmp/go/TestDirect_go.ts +123 -2
  35. package/project/.sdk/src/cmp/java/ReadmeRef_java.ts +5 -0
  36. package/project/.sdk/src/cmp/js/ReadmeRef_js.ts +5 -0
  37. package/project/.sdk/src/cmp/js/TestDirect_js.ts +62 -0
  38. package/project/.sdk/src/cmp/kotlin/ReadmeRef_kotlin.ts +5 -0
  39. package/project/.sdk/src/cmp/lean/ReadmeRef_lean.ts +27 -3
  40. package/project/.sdk/src/cmp/lua/ReadmeRef_lua.ts +5 -0
  41. package/project/.sdk/src/cmp/ocaml/Config_ocaml.ts +13 -1
  42. package/project/.sdk/src/cmp/ocaml/ReadmeRef_ocaml.ts +5 -0
  43. package/project/.sdk/src/cmp/perl/ReadmeRef_perl.ts +5 -0
  44. package/project/.sdk/src/cmp/php/ReadmeRef_php.ts +5 -0
  45. package/project/.sdk/src/cmp/py/ReadmeRef_py.ts +5 -0
  46. package/project/.sdk/src/cmp/rb/ReadmeRef_rb.ts +5 -0
  47. package/project/.sdk/src/cmp/rust/ReadmeRef_rust.ts +5 -0
  48. package/project/.sdk/src/cmp/scala/ReadmeRef_scala.ts +5 -0
  49. package/project/.sdk/src/cmp/seneca-provider/Extras_seneca-provider.ts +19 -1
  50. package/project/.sdk/src/cmp/seneca-provider/Main_seneca-provider.ts +27 -2
  51. package/project/.sdk/src/cmp/swift/ReadmeRef_swift.ts +5 -0
  52. package/project/.sdk/src/cmp/ts/Config_ts.ts +4 -1
  53. package/project/.sdk/src/cmp/ts/ReadmeHowto_ts.ts +9 -4
  54. package/project/.sdk/src/cmp/ts/ReadmeModel_ts.ts +8 -3
  55. package/project/.sdk/src/cmp/ts/ReadmeQuick_ts.ts +3 -2
  56. package/project/.sdk/src/cmp/ts/ReadmeRef_ts.ts +7 -2
  57. package/project/.sdk/src/cmp/ts/ReadmeTopQuick_ts.ts +3 -2
  58. package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +5 -2
  59. package/project/.sdk/src/cmp/ts/TestEntity_ts.ts +5 -2
  60. package/project/.sdk/src/cmp/zig/ReadmeRef_zig.ts +5 -0
  61. package/project/.sdk/tm/clojure/src/sdk/features.clj +175 -0
  62. package/project/.sdk/tm/dart/test/feature_test.dart +54 -0
  63. package/project/.sdk/tm/go/feature/test_feature.go +17 -6
  64. package/project/.sdk/tm/go/test/feature_corpus_test.go +28 -3
  65. package/project/.sdk/tm/java/test/FeatureCorpusTest.java +13 -4
  66. package/project/.sdk/tm/js/src/feature/test/TestFeature.js +8 -1
  67. package/project/.sdk/tm/js/src/utility/MakeOptionsUtility.js +3 -1
  68. package/project/.sdk/tm/js/test/feature/Corpus.test.js +19 -4
  69. package/project/.sdk/tm/js/test/feature.test.js +19 -9
  70. package/project/.sdk/tm/lean/src/SdkFeatures.lean +186 -1
  71. package/project/.sdk/tm/lua/feature/test_feature.lua +21 -7
  72. package/project/.sdk/tm/lua/test/feature_test.lua +18 -18
  73. package/project/.sdk/tm/lua/utility/make_options.lua +2 -0
  74. package/project/.sdk/tm/ocaml/sdk_features.ml +229 -0
  75. package/project/.sdk/tm/perl/t/feature_corpus.t +14 -5
  76. package/project/.sdk/tm/php/feature/TestFeature.php +19 -8
  77. package/project/.sdk/tm/php/test/FeatureCorpusTest.php +18 -4
  78. package/project/.sdk/tm/php/utility/MakeOptions.php +2 -1
  79. package/project/.sdk/tm/py/pkg/feature/test_feature.py +15 -4
  80. package/project/.sdk/tm/py/pkg/utility/make_options.py +2 -0
  81. package/project/.sdk/tm/py/test/feature_harness.py +6 -0
  82. package/project/.sdk/tm/py/test/test_feature.py +40 -0
  83. package/project/.sdk/tm/py/test/test_feature_corpus.py +20 -3
  84. package/project/.sdk/tm/rb/test/feature_corpus_test.rb +18 -5
  85. package/project/.sdk/tm/rust/feature/cost.rs +25 -1
  86. package/project/.sdk/tm/ts/src/feature/test/TestFeature.ts +15 -1
  87. package/project/.sdk/tm/ts/src/utility/MakeOptionsUtility.ts +3 -1
  88. package/project/.sdk/tm/ts/src/utility/PrepareAuthUtility.ts +20 -0
  89. package/project/.sdk/tm/ts/test/feature/Corpus.test.ts +25 -4
  90. package/project/.sdk/tm/ts/test/feature.test.ts +19 -9
  91. package/project/sdkgen-package.json +1 -1
  92. package/src/cmp/FeatureDocs.ts +100 -0
  93. package/src/cmp/Readme.ts +2 -0
  94. package/src/cmp/ReadmeFeatures.ts +113 -0
  95. package/src/cmp/ReadmeRef.ts +4 -0
  96. package/src/cmp/ReadmeRefFeatures.ts +149 -0
  97. package/src/cmp/ReadmeTop.ts +48 -0
  98. package/src/helpers/opShape.ts +5 -1
  99. package/src/sdkgen.ts +28 -7
  100. package/src/utility.ts +21 -1
@@ -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 key = string.match(restf, "^`body%.([^`%.]+)`$")
65
- if key == nil then
64
+ local path = string.match(restf, "^`body%.(.+)`$")
65
+ if path == nil then
66
66
  return data
67
67
  end
68
- return { [key] = data }
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
- -- Get required params.
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 reqd = vs.transform(reqd_params, { "`$EACH`", "", "`$KEY.name`" })
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 selected = vs.select(reqd, key)
326
- local is_reqd = not vs.isempty(selected)
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" } } })
@@ -75,11 +75,13 @@ local function make_options_util(ctx)
75
75
 
76
76
  local optspec = {
77
77
  apikey = "",
78
+ secret = "",
78
79
  base = "http://localhost:8000",
79
80
  prefix = "",
80
81
  suffix = "",
81
82
  auth = {
82
83
  prefix = "",
84
+ basic = false,
83
85
  },
84
86
  headers = {
85
87
  ["`$CHILD`"] = "`$STRING`",
@@ -592,6 +592,235 @@ let audit_feature () : feature =
592
592
  | _ -> ());
593
593
  f
594
594
 
595
+ (* ------------------------------------------------------------------ *)
596
+ (* cost *)
597
+ (* ------------------------------------------------------------------ *)
598
+ (* Prices every transport ATTEMPT and commits the spend once per
599
+ OPERATION. Mirrors tm/ts/src/feature/cost/CostFeature.ts; the corpus
600
+ cases are .sdk/test/feature/cost.aon.
601
+
602
+ ORDER MATTERS. Cost must sit INSIDE the cache, or a response served from
603
+ cache is charged for money that was never spent. Activate in array form
604
+ with cost first: [{ name: 'cost' }, { name: 'cache' }]. *)
605
+
606
+ let cost_feature () : feature =
607
+ let options = ref (empty_map ()) in
608
+ (* Per-context accumulation: attempts are priced as they happen and the
609
+ total is committed once, at PreDone. *)
610
+ let pending : (string, value) Hashtbl.t = Hashtbl.create 8 in
611
+ let seq = ref 0 in
612
+ let f = { f_name = "cost"; f_version = "0.0.1"; f_active = true; f_options = Noval;
613
+ f_init = (fun _ _ -> ()); f_hook = (fun _ _ -> ()) } in
614
+
615
+ let per_unit () = opt_num !options "perUnit" ~default:0. in
616
+ let limit () = opt_num !options "budget" ~default:0. in
617
+
618
+ let record ctx =
619
+ let cl = cc ctx in
620
+ track_bucket cl "cost" (fun () ->
621
+ jo [("currency", Str (opt_str !options "currency" ~default:"USD"));
622
+ ("total", jo [("calls", Num 0.); ("attempts", Num 0.); ("amount", Num 0.);
623
+ ("reported", Num 0.); ("estimated", Num 0.)]);
624
+ ("ops", empty_map ()); ("actors", empty_map ());
625
+ ("budget", jo [("limit", Num (limit ())); ("spent", Num 0.);
626
+ ("remaining", Num (limit ())); ("exceeded", Bool false)]);
627
+ ("last", Noval)]) in
628
+
629
+ let new_pending () =
630
+ jo [("attempts", Num 0.); ("amount", Num 0.); ("reported", Num 0.);
631
+ ("estimated", Num 0.); ("source", Str "none"); ("piped", Bool false)] in
632
+
633
+ let pending_for ctx =
634
+ match Hashtbl.find_opt pending ctx.c_id with
635
+ | Some p -> p
636
+ | None -> let p = new_pending () in Hashtbl.replace pending ctx.c_id p; p in
637
+
638
+ (* A header figure is server-stated, so it beats the table and the unit. *)
639
+ let price_header res =
640
+ let name = opt_str !options "header" ~default:"" in
641
+ if name = "" then None
642
+ else match header_ci (getp res "headers") name with
643
+ | Noval | Null -> None
644
+ | v -> (match float_of_string_opt (vstr_of v) with
645
+ | Some n -> Some (n *. per_unit (), "header")
646
+ | None -> None) in
647
+
648
+ (* Same lookup grammar as rbac's rules: '<entity>.<op>', then '<op>',
649
+ then '*'. *)
650
+ let price_rate ctx =
651
+ let rates = match getp !options "rates" with Map _ as m -> m | _ -> empty_map () in
652
+ let entity = match ctx.c_entity with
653
+ | Some e -> e.e_name
654
+ | None -> ctx.c_op.op_entity in
655
+ let opname = ctx.c_op.op_name in
656
+ let pick k = match getp rates k with Num n -> Some n | _ -> None in
657
+ match pick (entity ^ "." ^ opname) with
658
+ | Some n -> Some (n, "table")
659
+ | None -> (match pick opname with
660
+ | Some n -> Some (n, "table")
661
+ | None -> (match pick "*" with Some n -> Some (n, "table") | None -> None)) in
662
+
663
+ let price ctx res =
664
+ match price_header res with
665
+ | Some p -> p
666
+ | None ->
667
+ match price_rate ctx with
668
+ | Some p -> p
669
+ | None ->
670
+ let unit = opt_num !options "unit" ~default:0. in
671
+ if unit <> 0. then (unit, "unit") else (0., "none") in
672
+
673
+ (* A usage figure from the parsed result body, priced by perUnit. Read at
674
+ commit, not at the transport seam, because the body is one-shot. *)
675
+ let price_body ctx =
676
+ let path = opt_str !options "path" ~default:"" in
677
+ if path = "" then None
678
+ else match ctx.c_result with
679
+ | None -> None
680
+ | Some rt ->
681
+ (match rt.rt_body with
682
+ | Map _ ->
683
+ (match getpath_s rt.rt_body path with
684
+ | Num n -> Some (n *. per_unit ())
685
+ | Str s -> (match float_of_string_opt s with
686
+ | Some n -> Some (n *. per_unit ()) | None -> None)
687
+ | _ -> None)
688
+ | _ -> None) in
689
+
690
+ let spend rec_ amount reported estimated =
691
+ let total = getp rec_ "total" in
692
+ bump_num total "amount" amount;
693
+ bump_num total "reported" reported;
694
+ bump_num total "estimated" estimated;
695
+ let budget = getp rec_ "budget" in
696
+ let lim = match getp budget "limit" with Num n -> n | _ -> 0. in
697
+ let spent = match getp total "amount" with Num n -> n | _ -> 0. in
698
+ setp budget "spent" (Num spent);
699
+ setp budget "remaining" (Num (if lim > 0. then max 0. (lim -. spent) else 0.));
700
+ if lim > 0. && spent >= lim then setp budget "exceeded" (Bool true) in
701
+
702
+ let bump bucket key amount =
703
+ let b = match getp bucket key with
704
+ | Map _ as m -> m
705
+ | _ -> let m = jo [("calls", Num 0.); ("amount", Num 0.)] in setp bucket key m; m in
706
+ bump_num b "calls" 1.;
707
+ bump_num b "amount" amount in
708
+
709
+ let commit ctx p entity opname =
710
+ let rec_ = record ctx in
711
+ let amount = ref (match getp p "amount" with Num n -> n | _ -> 0.) in
712
+ let reported = ref (match getp p "reported" with Num n -> n | _ -> 0.) in
713
+ let estimated = ref (match getp p "estimated" with Num n -> n | _ -> 0.) in
714
+ let source = ref (vstr_of (getp p "source")) in
715
+
716
+ (* A body figure prices the whole call, so it REPLACES the per-attempt
717
+ estimate rather than adding to it - and, being server-stated, the
718
+ whole amount counts as reported. *)
719
+ (match price_body ctx with
720
+ | Some b -> amount := b; reported := b; estimated := 0.; source := "body"
721
+ | None -> ());
722
+
723
+ spend rec_ !amount !reported !estimated;
724
+
725
+ let actor = match ctx.c_ctrl.ctrl_actor with
726
+ | Str a when a <> "" -> a
727
+ | _ -> opt_str !options "actor" ~default:"anonymous" in
728
+
729
+ bump_num (getp rec_ "total") "calls" 1.;
730
+ bump (getp rec_ "ops") (entity ^ "." ^ opname) !amount;
731
+ bump (getp rec_ "actors") actor !amount;
732
+
733
+ incr seq;
734
+ setp rec_ "last"
735
+ (jo [("seq", Num (float_of_int !seq)); ("entity", Str entity); ("op", Str opname);
736
+ ("actor", Str actor); ("amount", Num !amount);
737
+ ("currency", getp rec_ "currency"); ("source", Str !source);
738
+ ("attempts", getp p "attempts")]) in
739
+
740
+ let finish ctx done_ =
741
+ if f.f_active then
742
+ match Hashtbl.find_opt pending ctx.c_id with
743
+ | None -> ()
744
+ | Some p ->
745
+ Hashtbl.remove pending ctx.c_id;
746
+ let attempts = match getp p "attempts" with Num n -> n | _ -> 0. in
747
+ (* A FAILED operation that made no attempt never reached the network:
748
+ the budget gate (or rbac, or an unresolvable endpoint) refused it.
749
+ Committing would count a call that never happened.
750
+
751
+ A SUCCEEDED operation that made no attempt is the opposite case: it
752
+ was served from the cache. That is a real call, and the fact that it
753
+ cost nothing is the whole point of ordering cost inside the cache. *)
754
+ if done_ || attempts > 0. then begin
755
+ let entity = if ctx.c_op.op_entity <> "" then ctx.c_op.op_entity else "_" in
756
+ let opname = if ctx.c_op.op_name <> "" then ctx.c_op.op_name else "_" in
757
+ commit ctx p entity opname
758
+ end in
759
+
760
+ let charge ctx url fetchdef inner =
761
+ (* A rejecting transport still costs an attempt: without this a run of
762
+ connection-level failures under `retry` would be charged nothing, and
763
+ an onBudget: 'deny' ceiling could never stop it. *)
764
+ let (res, err) = inner ctx url fetchdef in
765
+ let (amount, source) = price ctx res in
766
+ let p = pending_for ctx in
767
+ bump_num p "attempts" 1.;
768
+ bump_num p "amount" amount;
769
+ bump_num p (if source = "header" || source = "body" then "reported" else "estimated") amount;
770
+ setp p "source" (Str source);
771
+ bump_num (getp (record ctx) "total") "attempts" 1.;
772
+
773
+ (* direct() and graphql() dispatch no pipeline hooks at all - no PrePoint
774
+ to gate on and no PreDone to commit - so their spend is committed here
775
+ or never. `piped` is set by PrePoint, so its absence is the signal. *)
776
+ if getp p "piped" <> Bool true then begin
777
+ Hashtbl.remove pending ctx.c_id;
778
+ commit ctx p "_" "direct"
779
+ end;
780
+ (res, err) in
781
+
782
+ f.f_init <- (fun ctx opts ->
783
+ options := (match to_map opts with Map _ -> opts | _ -> empty_map ());
784
+ f.f_active <- opt_active opts;
785
+ if f.f_active then begin
786
+ Hashtbl.reset pending;
787
+ seq := 0;
788
+ ignore (record ctx);
789
+ let u = cu ctx in
790
+ let inner = u.u_fetcher in
791
+ u.u_fetcher <- (fun fctx url fd -> charge fctx url fd inner)
792
+ end);
793
+
794
+ f.f_hook <- (fun name ctx ->
795
+ if f.f_active then
796
+ match name with
797
+ | "PrePoint" ->
798
+ (* Mark the context as piped, so charge knows a PreDone is coming. *)
799
+ let p = pending_for ctx in
800
+ setp p "piped" (Bool true);
801
+ let lim = limit () in
802
+ if lim > 0. then begin
803
+ let rec_ = record ctx in
804
+ let spent = match getp (getp rec_ "total") "amount" with Num n -> n | _ -> 0. in
805
+ if spent >= lim then begin
806
+ setp (getp rec_ "budget") "exceeded" (Bool true);
807
+ if opt_str !options "onBudget" ~default:"warn" = "deny" then begin
808
+ let err = ctx_make_error ctx "cost_budget"
809
+ ("Cost budget of " ^ js_string (Num lim) ^ " " ^ vstr_of (getp rec_ "currency")
810
+ ^ " is spent (" ^ js_string (Num spent) ^ " used)") in
811
+ Hashtbl.replace ctx.c_out "point" (OErr err)
812
+ end
813
+ end
814
+ end
815
+ | "PreDone" -> finish ctx true
816
+ (* A failed operation never reaches PreDone - make_error dispatches
817
+ PreUnexpected - so its attempts would be priced and discarded, and
818
+ repeated failures could slip past an onBudget "deny" ceiling.
819
+ `false` keeps a call that made NO attempt from being counted. *)
820
+ | "PreUnexpected" -> finish ctx false
821
+ | _ -> ());
822
+ f
823
+
595
824
  (* ------------------------------------------------------------------ *)
596
825
  (* clienttrack *)
597
826
  (* ------------------------------------------------------------------ *)
@@ -29,9 +29,10 @@ unless (defined $TEST_JSON && -e $TEST_JSON) {
29
29
  plan skip_all => 'test.json corpus not found';
30
30
  }
31
31
 
32
- # Features with a corpus section. A name here with no section is a skip, not
33
- # a failure: an SDK generated without the feature has nothing to run.
34
- my @FEATURE_CORPUS_NAMES = ('cost');
32
+ # Features with a corpus section are read from the corpus itself (see the
33
+ # loop below), so a project-authored section - a custom feature added under
34
+ # .sdk/test/feature/ - runs without editing this file. An SDK generated
35
+ # without a listed feature still skips, not fails.
35
36
 
36
37
  # The standard operation names, in the order the runner prefers them.
37
38
  my @FEATURE_CORPUS_OPS = qw(load list create update remove);
@@ -144,7 +145,15 @@ sub candidates {
144
145
  };
145
146
  }
146
147
  }
147
- return @out;
148
+
149
+ # SAFE OPS FIRST - see the ts harness for the reasoning: the cache stores
150
+ # only successful GETs, so an SDK whose first usable op is a `create`
151
+ # (POST) can never satisfy "a hit served from cache costs nothing".
152
+ my %safe = (list => 0, load => 1);
153
+ return sort {
154
+ ($safe{ $a->{op} } // 2) <=> ($safe{ $b->{op} } // 2)
155
+ || $a->{key} cmp $b->{key}
156
+ } @out;
148
157
  }
149
158
 
150
159
 
@@ -274,7 +283,7 @@ ok(scalar(@ops) > 0,
274
283
  exit 0;
275
284
  };
276
285
 
277
- for my $name (@FEATURE_CORPUS_NAMES) {
286
+ for my $name (sort keys %{ $CORPUS->{feature} || {} }) {
278
287
  my $section = ($CORPUS->{feature} || {})->{$name};
279
288
  next unless defined $section;
280
289
 
@@ -69,10 +69,16 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
69
69
  if (!is_string($restf)) {
70
70
  return $data;
71
71
  }
72
- if (!preg_match('/^`body\.([^`.]+)`$/', $restf, $m)) {
72
+ if (!preg_match('/^`body\.(.+)`$/', $restf, $m)) {
73
73
  return $data;
74
74
  }
75
- return [$m[1] => $data];
75
+ // Multi-segment on purpose: GraphQL ops unwrap body.data.<field>
76
+ // (and body.data.<field>.<entity> for mutations), not just one level.
77
+ $out = $data;
78
+ foreach (array_reverse(explode('.', $m[1])) as $seg) {
79
+ $out = [$seg => $out];
80
+ }
81
+ return $out;
76
82
  };
77
83
 
78
84
  $respond = function (int $status, mixed $data, ?array $extra = null) use ($envelope): array {
@@ -433,13 +439,18 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
433
439
  }
434
440
  }
435
441
  }
442
+ // Path AND query: a path-only read misses a query-addressed
443
+ // record (e.g. GET /result?trace_id=), which has no path param.
436
444
  $params = is_array($point) ? ($point['args']['params'] ?? null) : null;
437
- if (is_array($params)) {
438
- foreach ($params as $p) {
439
- if (is_array($p) && (($p['reqd'] ?? false) === true)) {
440
- $n = $p['name'] ?? null;
441
- if ($n !== null) {
442
- $reqd_names[] = $n;
445
+ $query = is_array($point) ? ($point['args']['query'] ?? null) : null;
446
+ foreach ([$params, $query] as $arglist) {
447
+ if (is_array($arglist)) {
448
+ foreach ($arglist as $p) {
449
+ if (is_array($p) && (($p['reqd'] ?? false) === true)) {
450
+ $n = $p['name'] ?? null;
451
+ if ($n !== null) {
452
+ $reqd_names[] = $n;
453
+ }
443
454
  }
444
455
  }
445
456
  }
@@ -23,9 +23,10 @@ use PHPUnit\Framework\TestCase;
23
23
 
24
24
  class FeatureCorpusTest extends TestCase
25
25
  {
26
- // Features with a corpus section. A name here with no section is a skip,
27
- // not a failure: an SDK generated without the feature has nothing to run.
28
- private const FEATURE_CORPUS_NAMES = ['cost'];
26
+ // Features with a corpus section are read from the corpus itself (see
27
+ // testFeatureCorpus), so a project-authored section - a custom feature
28
+ // added under .sdk/test/feature/ - runs without editing this file. An
29
+ // SDK generated without a listed feature still skips, not fails.
29
30
 
30
31
  // The standard operation names, in the order the runner prefers them.
31
32
  private const FEATURE_CORPUS_OPS = ['load', 'list', 'create', 'update', 'remove'];
@@ -145,6 +146,17 @@ class FeatureCorpusTest extends TestCase
145
146
  }
146
147
  }
147
148
  }
149
+
150
+ // SAFE OPS FIRST — see the ts harness for the reasoning: the cache
151
+ // stores only successful GETs, so an SDK whose first usable op is a
152
+ // `create` (POST) can never satisfy "a hit served from cache costs
153
+ // nothing".
154
+ $safe = ['list' => 0, 'load' => 1];
155
+ usort($out, function ($a, $b) use ($safe) {
156
+ $ra = $safe[$a['op']] ?? 2;
157
+ $rb = $safe[$b['op']] ?? 2;
158
+ return $ra === $rb ? strcmp($a['key'], $b['key']) : $ra - $rb;
159
+ });
148
160
  return $out;
149
161
  }
150
162
 
@@ -289,7 +301,9 @@ class FeatureCorpusTest extends TestCase
289
301
  . 'the corpus (create-sdkgen .sdk/test/feature/) to run these cases');
290
302
  }
291
303
 
292
- foreach (self::FEATURE_CORPUS_NAMES as $name) {
304
+ $names = array_keys(self::corpus()['feature'] ?? []);
305
+ sort($names);
306
+ foreach ($names as $name) {
293
307
  $section = self::corpus()['feature'][$name] ?? null;
294
308
  if (null === $section) {
295
309
  continue;
@@ -130,10 +130,11 @@ class ProjectNameMakeOptions
130
130
 
131
131
  $optspec = [
132
132
  'apikey' => '',
133
+ 'secret' => '',
133
134
  'base' => 'http://localhost:8000',
134
135
  'prefix' => '',
135
136
  'suffix' => '',
136
- 'auth' => ['prefix' => ''],
137
+ 'auth' => ['prefix' => '', 'basic' => false],
137
138
  'headers' => ['`$CHILD`' => '`$STRING`'],
138
139
  'allow' => [
139
140
  'method' => 'GET,PUT,POST,PATCH,DELETE,OPTIONS',
@@ -11,7 +11,7 @@ from projectname_sdk.feature.base_feature import ProjectNameBaseFeature
11
11
 
12
12
  # The `body.<key>` form of an op's response transform: the mock wraps its
13
13
  # payload in <key> so the transform can unwrap it again.
14
- ENVELOPE_RES_RE = re.compile(r"^`body\.([^`.]+)`$")
14
+ ENVELOPE_RES_RE = re.compile(r"^`body\.(.+)`$")
15
15
 
16
16
 
17
17
  class ProjectNameTestFeature(ProjectNameBaseFeature):
@@ -60,7 +60,14 @@ class ProjectNameTestFeature(ProjectNameBaseFeature):
60
60
  if not isinstance(restf, str):
61
61
  return data
62
62
  m = ENVELOPE_RES_RE.match(restf)
63
- return data if m is None else {m.group(1): data}
63
+ if m is None:
64
+ return data
65
+ # Multi-segment on purpose: GraphQL ops unwrap body.data.<field>
66
+ # (and body.data.<field>.<entity> for mutations), not just one level.
67
+ out = data
68
+ for seg in reversed(m.group(1).split(".")):
69
+ out = {seg: out}
70
+ return out
64
71
 
65
72
  def respond(status, data, extra=None):
66
73
  payload = envelope(data)
@@ -260,10 +267,14 @@ class ProjectNameTestFeature(ProjectNameBaseFeature):
260
267
  points = vs.getpath(ctx.config, "entity." + ctx.entity.get_name() + ".op." + opname + ".points")
261
268
  point = vs.getelem(points, -1)
262
269
 
263
- # Get required params.
270
+ # Path AND query: a path-only read misses a query-addressed record
271
+ # (e.g. GET /result?trace_id=), which has no path param at all.
264
272
  params_path = vs.getpath(point, "args.params")
265
273
  reqd_params = vs.select(params_path, {"reqd": True})
266
- reqd = vs.transform(reqd_params, ["`$EACH`", "", "`$KEY.name`"])
274
+ query_path = vs.getpath(point, "args.query")
275
+ reqd_query = vs.select(query_path, {"reqd": True})
276
+ reqd = (vs.transform(reqd_params, ["`$EACH`", "", "`$KEY.name`"]) or []) + \
277
+ (vs.transform(reqd_query, ["`$EACH`", "", "`$KEY.name`"]) or [])
267
278
 
268
279
  qand = []
269
280
  q = {"`$AND`": qand}
@@ -97,11 +97,13 @@ def make_options_util(ctx):
97
97
 
98
98
  optspec = {
99
99
  "apikey": "",
100
+ "secret": "",
100
101
  "base": "http://localhost:8000",
101
102
  "prefix": "",
102
103
  "suffix": "",
103
104
  "auth": {
104
105
  "prefix": "",
106
+ "basic": False,
105
107
  },
106
108
  "headers": {
107
109
  "`$CHILD`": "`$STRING`",
@@ -165,6 +165,12 @@ class _Ctx:
165
165
  self.meta = {}
166
166
  self.op = op
167
167
  self.entity = entity
168
+ # The pipeline always resolves a point before a feature sees the ctx,
169
+ # so features read ctx.point freely — paging checks `point.kind` for
170
+ # graphql. This stub had no such attribute, so every paging test died
171
+ # with AttributeError before reaching the transport. Those tests were
172
+ # SKIPPED until an SDK activated paging, which is why it went unseen.
173
+ self.point = {}
168
174
  self.spec = None
169
175
  self.response = None
170
176
  self.result = None