@voxgig/sdkgen 2.0.1 → 2.0.2

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 CHANGED
@@ -8,7 +8,7 @@ const { Shape, One } = require('shape')
8
8
 
9
9
  const { SdkGen } = require('../dist/sdkgen.js')
10
10
 
11
- const VERSION = '1.3.18'
11
+ const VERSION = '2.0.2'
12
12
  const KONSOLE = console
13
13
 
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxgig/sdkgen",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "main": "dist/sdkgen.js",
5
5
  "type": "commonjs",
6
6
  "engines": {
@@ -25,7 +25,7 @@
25
25
  "test": "node --enable-source-maps --test dist-test/**/*.test.js",
26
26
  "test-some": "node --enable-source-maps --test-name-pattern=\"$npm_config_pattern\" --test dist-test/**/*.test.js",
27
27
  "watch": "tsc --build src test -w",
28
- "build": "tsc --build src test && npm run check-scaffold",
28
+ "build": "tsc --build src test && npm run check-scaffold && npm run stage-scaffold",
29
29
  "clean": "rm -rf dist dist-test node_modules yarn.lock package-lock.json",
30
30
  "reset": "npm run clean && npm i && npm run build && npm test",
31
31
  "embed-version": "node build/version.js",
@@ -38,7 +38,8 @@
38
38
  "repo-publish-quick-dry": "npm run build && npm run test && ( npm stage publish --dry-run --registry https://registry.npmjs.org --access=public || echo \"[dry-run] npm stage publish refused (see npm error above) - usually the version is already published; bump package.json before a real release\" )",
39
39
  "repo-release": "npm run repo-publish && npm run repo-tag",
40
40
  "repo-release-dry": "npm run repo-publish-quick-dry && npm run repo-tag-dry",
41
- "check-scaffold": "tsc -p tsconfig.scaffold.json"
41
+ "check-scaffold": "tsc -p tsconfig.scaffold.json",
42
+ "stage-scaffold": "node build/scaffold-stage.js && tsc -p tsconfig.scaffold-emit.json"
42
43
  },
43
44
  "license": "MIT",
44
45
  "files": [
@@ -148,6 +148,14 @@ static void ${evar}_entity_instance() {
148
148
  ASSERT_EQ(ent->getName(), std::string("${entity.name}"), "entity name");
149
149
  }
150
150
 
151
+ `)
152
+
153
+ // The stream test drives the list op; only emit it when the entity has a
154
+ // list op (a create/load-only entity has no list endpoint, so
155
+ // stream("list") throws "Operation \"list\" has no endpoint definitions").
156
+ const flowHasList = allSteps.some((s: any) => 'list' === s.op)
157
+ if (flowHasList) {
158
+ Content(`
151
159
  static void ${evar}_entity_stream() {
152
160
  // stream() runs the list op through the full pipeline and returns the
153
161
  // result items. Seed two entities via test mode; with the streaming feature
@@ -169,7 +177,10 @@ static void ${evar}_entity_stream() {
169
177
  std::vector<Value> pitems = pe->stream("list", Value::undef(), Value::undef());
170
178
  ASSERT_EQ((int)pitems.size(), 2, "fallback stream yields both items");
171
179
  }
180
+ `)
181
+ }
172
182
 
183
+ Content(`
173
184
  static void ${evar}_entity_basic() {
174
185
  auto setup = ${evar}_basic_setup(Value::undef());
175
186
  std::string mode = setup.live ? "live" : "unit";
@@ -212,8 +223,7 @@ static void ${evar}_entity_basic() {
212
223
 
213
224
  int main() {
214
225
  T_RUN(${evar}_entity_instance);
215
- T_RUN(${evar}_entity_stream);
216
- T_RUN(${evar}_entity_basic);
226
+ ${flowHasList ? ` T_RUN(${evar}_entity_stream);\n` : ''} T_RUN(${evar}_entity_basic);
217
227
  return sdktest::summary("${entity.name}_entity_test");
218
228
  }
219
229
  `)
@@ -58,6 +58,12 @@ const Config = cmp(async function Config(props: any) {
58
58
 
59
59
  replace: {
60
60
 
61
+ // Config.fragment.dart carries `'name': 'ProjectName'` — without the
62
+ // standard replacements the generated SDK reports "ProjectName" as its
63
+ // own name at runtime. Every sibling dart component already spreads
64
+ // these; this one did not.
65
+ ...ctx$.stdrep,
66
+
61
67
  "'AUTHBLOCK'": authBlock,
62
68
 
63
69
  "'HEADERS'": dartValue(headers, 2),
@@ -55,14 +55,17 @@ IO.inspect(records)
55
55
  .filter((it: any) => !it.optional || it.name === idF)
56
56
  .sort((a: any, b: any) =>
57
57
  (a.name === idF ? 0 : 1) - (b.name === idF ? 0 : 1))
58
- const loadArg = 0 < loadItems.length
59
- ? `H.deep(%{${loadItems.map((it: any) =>
58
+ // No required match keys (a singleton endpoint like /current) means no
59
+ // second argument at all — `load(ent, )` is a syntax error, and
60
+ // reqmatch defaults to nil, so load/1 is the correct call.
61
+ const loadArgs = 0 < loadItems.length
62
+ ? `${eVar}, H.deep(%{${loadItems.map((it: any) =>
60
63
  `"${it.name}" => ${elixirLit(it.type,
61
64
  it.name === idF ? 'example_id' : 'example_' + it.name)}`).join(', ')}})`
62
- : ''
65
+ : eVar
63
66
  Content(`
64
67
  # Load a specific ${eName.toLowerCase()} (returns the record, raises on error)
65
- record = ${Name}.Entity.${eName}.load(${eVar}, ${loadArg})
68
+ record = ${Name}.Entity.${eName}.load(${loadArgs})
66
69
  IO.inspect(record)
67
70
  `)
68
71
  }
@@ -26,6 +26,21 @@ function hasParamFreePoint(op: any): boolean {
26
26
  }
27
27
 
28
28
 
29
+ // True when an op selects a single record by path params — i.e. loading it by
30
+ // id is meaningful. A load whose every point is param-free is a singleton
31
+ // endpoint (`/current`): passing it an id matches no point at all, and the
32
+ // runtime raises `has no matching endpoint`.
33
+ function selectsByParams(op: any): boolean {
34
+ if (null == op) return false
35
+ const points = op.points || []
36
+ for (const pt of points) {
37
+ const params = ((pt.args || {}).params) || []
38
+ if (0 < params.length) return true
39
+ }
40
+ return false
41
+ }
42
+
43
+
29
44
  // Synthesize a create payload from the entity's required non-id fields.
30
45
  function synthData(fields: any): any {
31
46
  const o: any = {}
@@ -64,30 +79,44 @@ const Test = cmp(async function Test(props: any) {
64
79
  const Name = e.Name || (e.name.charAt(0).toUpperCase() + e.name.slice(1))
65
80
  const ops = e.op || {}
66
81
  if (!ops.list && !ops.load) return
67
- offline += ` -- ${e.name}: offline (test-mode) entity behaviour
68
- (do
69
- let seedPath := "../.sdk/test/entity/${e.name}/${Name}TestData.json"
70
- if !(← System.FilePath.pathExists seedPath) then
71
- IO.println s!"skip - ${e.name}: no seed at {seedPath}"
72
- else do
73
- let seed SdkJson.jsonRead (← IO.FS.readFile seedPath)
74
- let tclient Sdk.testSdk0 seed
75
- let existing ← SdkRuntime.gp (← SdkRuntime.gp seed "existing") "${e.name}"
76
- let ids ← keysof existing
77
- -- list returns every seeded entity
82
+
83
+ // Only call the ops the entity actually declares. `Entity.create` is not
84
+ // generated for a load-only entity, so emitting the create/remove block
85
+ // unconditionally fails the build with "Unknown identifier X.create".
86
+ // Value's constructors are spelled out (`Value.list`, not `.list`) the
87
+ // expected type is not known at the match scrutinee, and Lean reports the
88
+ // dotted form as ambiguous against Std's own `.list`.
89
+ let body = ''
90
+
91
+ if (ops.list) {
92
+ body += ` -- list returns every seeded entity
78
93
  let items ← ${ns}.list tclient (← emptyMap) (← emptyMap)
79
- let n ← (match items with | .list lid => do pure (← listItems lid).size | _ => pure 0)
94
+ let n ← (match items with | Value.list lid => do pure (← listItems lid).size | _ => pure 0)
80
95
  if n == ids.size then pass s!"${e.name}.list offline -> {n} seeded"
81
96
  else fail s!"${e.name}.list offline: got {n}, want {ids.size}"
82
- -- load the first seeded entity by id
97
+ `
98
+ }
99
+
100
+ if (ops.load && selectsByParams(ops.load)) {
101
+ body += ` -- load the first seeded entity by id
83
102
  if ids.size > 0 then do
84
103
  let wid := ids[0]!
85
- let m ← newMap #[("id", .str wid)]
104
+ let m ← newMap #[("id", Value.str wid)]
86
105
  let got ← ${ns}.load tclient m (← emptyMap)
87
106
  let gid ← SdkRuntime.gpS got "id"
88
107
  if gid == wid then pass s!"${e.name}.load offline (id={wid})"
89
108
  else fail s!"${e.name}.load offline: got {gid}, want {wid}"
90
- -- create -> load back -> remove, all in the store
109
+ `
110
+ }
111
+ // A singleton load (`/current`, `/slack`) is deliberately NOT asserted
112
+ // here. The offline lane answers from a store keyed by entity id, so a
113
+ // load carrying no id has nothing to look up — the only honest assertion
114
+ // would need the model-driven match data the other targets build in their
115
+ // TestEntity components. Until this lane is model-driven too, such an
116
+ // entity contributes no offline block rather than a misleading one.
117
+
118
+ if (ops.create && ops.load && ops.remove) {
119
+ body += ` -- create -> load back -> remove, all in the store
91
120
  let newmap ← SdkRuntime.gp (← SdkRuntime.gp seed "new") "${e.name}"
92
121
  let nks ← keysof newmap
93
122
  if nks.size > 0 then do
@@ -95,7 +124,7 @@ const Test = cmp(async function Test(props: any) {
95
124
  let created ← ${ns}.create tclient payload (← emptyMap)
96
125
  let cid ← SdkRuntime.gpS created "id"
97
126
  if cid == "" then fail "${e.name}.create offline returned no id" else do
98
- let m2 ← newMap #[("id", .str cid)]
127
+ let m2 ← newMap #[("id", Value.str cid)]
99
128
  let back ← ${ns}.load tclient m2 (← emptyMap)
100
129
  if (← SdkRuntime.gpS back "id") == cid then
101
130
  pass s!"${e.name}.create/load offline (id={cid})"
@@ -103,8 +132,26 @@ const Test = cmp(async function Test(props: any) {
103
132
  let _ ← ${ns}.remove tclient m2 (← emptyMap)
104
133
  let gone ← ${ns}.load tclient m2 (← emptyMap)
105
134
  match gone with
106
- | .map _ => fail s!"${e.name}.remove offline: still present"
107
- | _ => pass s!"${e.name}.remove offline (id={cid})")
135
+ | Value.map _ => fail s!"${e.name}.remove offline: still present"
136
+ | _ => pass s!"${e.name}.remove offline (id={cid})"
137
+ `
138
+ }
139
+
140
+ // Nothing to assert (an entity whose only op needs path params we cannot
141
+ // synthesise) — skip the block rather than emit an empty `do`.
142
+ if ('' === body) return
143
+
144
+ offline += ` -- ${e.name}: offline (test-mode) entity behaviour
145
+ (do
146
+ let seedPath := "../.sdk/test/entity/${e.name}/${Name}TestData.json"
147
+ if !(← System.FilePath.pathExists seedPath) then
148
+ IO.println s!"skip - ${e.name}: no seed at {seedPath}"
149
+ else do
150
+ let seed ← SdkJson.jsonRead (← IO.FS.readFile seedPath)
151
+ let tclient ← Sdk.testSdk0 seed
152
+ let existing ← SdkRuntime.gp (← SdkRuntime.gp seed "existing") "${e.name}"
153
+ let ids ← keysof existing
154
+ ${body})
108
155
  `
109
156
  })
110
157
 
@@ -121,7 +168,7 @@ const Test = cmp(async function Test(props: any) {
121
168
  (do
122
169
  let items ← ${ns}.list client (← emptyMap) (← emptyMap)
123
170
  match items with
124
- | .list lid => pass s!"${e.name}.list -> {(← listItems lid).size} items"
171
+ | Value.list lid => pass s!"${e.name}.list -> {(← listItems lid).size} items"
125
172
  | _ => fail "${e.name}.list did not return a list")
126
173
  `
127
174
  }
@@ -151,6 +198,13 @@ const Test = cmp(async function Test(props: any) {
151
198
  // The live lane nests two levels deeper than the offline lane.
152
199
  const liveBlocks = blocks.split('\n').map((l) => l ? ' ' + l : l).join('\n')
153
200
 
201
+ // A `do` block whose last statement is a `let` is a type error in Lean
202
+ // ("the rest of the do block has monadic result type"), and an entirely
203
+ // empty one does not parse at all. An API whose entities offer no op these
204
+ // lanes can drive leaves both empty, so terminate them explicitly.
205
+ const offlineBody = '' === offline ? ' pure ()\n' : offline
206
+ const liveBody = '' === blocks ? ' pure ()\n' : liveBlocks
207
+
154
208
  Folder({ name: 'test' }, () => {
155
209
  File({ name: 'Runner.' + target.ext }, () => {
156
210
  Content(`-- ${model.const.Name} SDK test runner (generated by @voxgig/sdkgen).
@@ -183,14 +237,14 @@ def main : IO UInt32 := do
183
237
  let liveBase ← IO.getEnv "SDK_TEST_BASE"
184
238
  let ctx ← mkCtx
185
239
  let offline : SIO Unit := do
186
- ${offline} offline.run ctx
240
+ ${offlineBody} offline.run ctx
187
241
  match liveBase with
188
242
  | none => IO.println "skip - live lane (set SDK_TEST_BASE to enable)"
189
243
  | some base => do
190
244
  let live : SIO Unit := do
191
245
  let opts ← newMap #[("base", .str base)]
192
246
  let client ← Sdk.newSdk opts
193
- ${liveBlocks} live.run ctx
247
+ ${liveBody} live.run ctx
194
248
  let p ← npass.get
195
249
  let f ← nfail.get
196
250
  IO.println ""
@@ -29,7 +29,10 @@ const Test = cmp(function Test(props: any) {
29
29
 
30
30
  // Generate exists test
31
31
  File({ name: 'test_exists.' + target.ext }, () => {
32
- Content(`# ProjectName SDK exists test
32
+ // The header names the SDK like every other line here — a literal
33
+ // `ProjectName` is the raw placeholder, not a substituted value: Content
34
+ // does not apply the standard replacements.
35
+ Content(`# ${model.const.Name} SDK exists test
33
36
 
34
37
  import pytest
35
38
  from ${model.const.Name.toLowerCase()}_sdk import ${model.const.Name}SDK
@@ -72,7 +72,7 @@ const char* graphql_error_code(voxgig_value* gqlerr) {
72
72
  // generated create/update call look exactly like its REST equivalent.
73
73
  voxgig_value* graphql_body_util(Context* ctx) {
74
74
  voxgig_value* gql = getp(ctx->point, "graphql");
75
- if (!voxgig_is_map(gql)) return voxgig_new_noval();
75
+ if (!voxgig_is_map(gql)) return voxgig_new_undef();
76
76
 
77
77
  // reqmatch/reqdata hold the caller's arguments for THIS call; data/match
78
78
  // hold the entity's current state. Which pair depends on whether the op
@@ -82,6 +82,8 @@ class Utility private constructor(register: Boolean) {
82
82
  u.prepareParams = this.prepareParams
83
83
  u.preparePath = this.preparePath
84
84
  u.prepareQuery = this.prepareQuery
85
+ u.graphqlBody = this.graphqlBody
86
+ u.graphqlErrors = this.graphqlErrors
85
87
  u.resultBasic = this.resultBasic
86
88
  u.resultBody = this.resultBody
87
89
  u.resultHeaders = this.resultHeaders
@@ -0,0 +1,25 @@
1
+ .PHONY: test build lint clean corpus
2
+
3
+ # Build every lean_lib and lean_exe declared in lakefile.toml.
4
+ build:
5
+ lake build
6
+
7
+ # Run the four generated test executables. `runner` drives the SDK pipeline
8
+ # and the generated entity/direct tests, `feature` the feature suite,
9
+ # `primary` the shared primary-utility corpus, and `structcorpus` the
10
+ # vendored voxgig/struct corpus. Any one exiting non-zero fails the target.
11
+ test:
12
+ lake exe runner
13
+ lake exe feature
14
+ lake exe primary
15
+ lake exe structcorpus
16
+
17
+ corpus:
18
+ lake exe structcorpus
19
+
20
+ # "Lint": a clean build means the code type-checks.
21
+ lint:
22
+ lake build
23
+
24
+ clean:
25
+ rm -rf .lake
@@ -231,13 +231,13 @@ def prepareQuery (ctx : Value) : SIO Value := do
231
231
  if !(isNov v) && !(names.contains k) then sp out k v
232
232
  pure out
233
233
 
234
+ /-- Assemble the path template from `point.parts`. This is `struct.join` with
235
+ the url flag — NOT a plain intercalate: empty segments are dropped (a doubled
236
+ slash changes the URL and 404s on strict routers) and there is no leading
237
+ slash, because makeUrl joins base/prefix/path/suffix with `joinurl`. -/
234
238
  def preparePath (ctx : Value) : SIO String := do
235
239
  let point ← gp ctx "point"
236
- match (← gp point "parts") with
237
- | .list i => do
238
- let segs := (← listItems i).map vs
239
- pure ("/" ++ String.intercalate "/" segs.toList)
240
- | _ => pure ""
240
+ join (← gp point "parts") (sep := .str "/") (url := true)
241
241
 
242
242
  /-- The API definition is authoritative: a POST-only or PATCH-based API
243
243
  exposes `update` as POST or PATCH, not the PUT the op name implies. Only
@@ -396,9 +396,11 @@ def graphqlBody (ctx : Value) : SIO Value := do
396
396
  match spec with
397
397
  | .map _ => do
398
398
  let name ← gpS spec "name"
399
- let from gpS spec "from"
399
+ -- `from` is a Lean keyword, so the binding cannot take the field's
400
+ -- own name.
401
+ let varFrom ← gpS spec "from"
400
402
  if name != "" then
401
- if from == "" then do
403
+ if varFrom == "" then do
402
404
  -- The input object IS the request body. Strip the action
403
405
  -- selector, which is an SDK-side point discriminator, not an API
404
406
  -- field.
@@ -409,8 +411,8 @@ def graphqlBody (ctx : Value) : SIO Value := do
409
411
  else do
410
412
  -- Only send variables the caller actually supplied: sending an
411
413
  -- explicit null would clear a field on many APIs.
412
- let v0 ← gp reqsrc from
413
- let v ← if isNullish v0 then gp datasrc from else pure v0
414
+ let v0 ← gp reqsrc varFrom
415
+ let v ← if isNullish v0 then gp datasrc varFrom else pure v0
414
416
  if !(isNullish v) then sp variables name v
415
417
  | _ => pure ()
416
418
  newMap #[("query", ← gp gql "doc"), ("variables", variables)]
@@ -306,9 +306,12 @@ def main : IO UInt32 := do
306
306
  -- The remaining corpus sections. They carry no cases in this project's
307
307
  -- corpus, but are driven here so any future fixture runs against Lean too.
308
308
 
309
+ -- clean takes (ctx, val), so the fixture supplies `args`, not `in` —
310
+ -- same shape as param/makeError above.
309
311
  runset "clean" (← getSpec primary #["clean", "basic"]) fun entry => do
310
- let ctxentryCtx entry opts config
311
- pure ((SdkUtility.clean ctx (← SdkUtility.gp entry "in")), none)
312
+ let argsSdkUtility.gp entry "args"
313
+ let ctx mapCtx entry (← argAt args 0) opts config
314
+ pure ((← SdkUtility.clean ctx (← argAt args 1)), none)
312
315
 
313
316
  runset "makeResult" (← getSpec primary #["makeResult", "basic"]) fun entry => do
314
317
  pure ((← SdkUtility.makeResult (← entryCtx entry opts config)), none)