@voxgig/sdkgen 4.17.5 → 4.18.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/AgentGuideContent.js +30 -0
- package/dist/cmp/AgentGuideContent.js.map +1 -1
- package/dist/cmp/FeatureDocs.d.ts +6 -1
- package/dist/cmp/FeatureDocs.js +27 -0
- package/dist/cmp/FeatureDocs.js.map +1 -1
- package/dist/cmp/ReadmeRefFeatures.js +23 -13
- package/dist/cmp/ReadmeRefFeatures.js.map +1 -1
- package/dist/helpers/applicability.js +7 -0
- package/dist/helpers/applicability.js.map +1 -1
- package/dist/helpers/canonSpec.d.ts +12 -0
- package/dist/helpers/canonSpec.js +259 -0
- package/dist/helpers/canonSpec.js.map +1 -0
- package/dist/helpers/optspec.d.ts +4 -0
- package/dist/helpers/optspec.js +152 -0
- package/dist/helpers/optspec.js.map +1 -0
- package/dist/sdkgen.d.ts +3 -1
- package/dist/sdkgen.js +12 -3
- package/dist/sdkgen.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/model/sdkgen.aon +125 -0
- package/package.json +1 -1
- package/project/.sdk/model/feature/audit.aon +15 -0
- package/project/.sdk/model/feature/cache.aon +13 -0
- package/project/.sdk/model/feature/clienttrack.aon +21 -0
- package/project/.sdk/model/feature/cost.aon +15 -0
- package/project/.sdk/model/feature/debug.aon +15 -0
- package/project/.sdk/model/feature/feature-index.aon +1 -0
- package/project/.sdk/model/feature/idempotency.aon +13 -0
- package/project/.sdk/model/feature/log.aon +13 -0
- package/project/.sdk/model/feature/metrics.aon +13 -0
- package/project/.sdk/model/feature/netsim.aon +14 -0
- package/project/.sdk/model/feature/paging.aon +15 -0
- package/project/.sdk/model/feature/proxy.aon +13 -0
- package/project/.sdk/model/feature/ratelimit.aon +15 -0
- package/project/.sdk/model/feature/retry.aon +15 -0
- package/project/.sdk/model/feature/streaming.aon +15 -0
- package/project/.sdk/model/feature/telemetry.aon +19 -0
- package/project/.sdk/model/feature/test.aon +13 -0
- package/project/.sdk/model/feature/timeout.aon +15 -0
- package/project/.sdk/model/feature/validate.aon +73 -0
- package/project/.sdk/model/target/js.aon +1 -1
- package/project/.sdk/model/target/ts.aon +1 -1
- package/project/.sdk/src/cmp/js/Main_js.ts +2 -0
- package/project/.sdk/src/cmp/js/Schema_js.ts +66 -0
- package/project/.sdk/src/cmp/scala/Main_scala.ts +30 -0
- package/project/.sdk/src/cmp/ts/Main_ts.ts +2 -0
- package/project/.sdk/src/cmp/ts/Schema_ts.ts +66 -0
- package/project/.sdk/tm/js/src/feature/validate/ValidateFeature.js +296 -0
- package/project/.sdk/tm/js/src/utility/MakeOptionsUtility.js +26 -61
- package/project/.sdk/tm/ts/src/feature/validate/ValidateFeature.ts +298 -0
- package/project/.sdk/tm/ts/src/utility/MakeOptionsUtility.ts +24 -61
- package/project/.sdk/tm/ts/test/feature/secrets/Secrets.test.ts +37 -8
- package/project/sdkgen-package.json +3 -2
- package/src/cmp/AgentGuideContent.ts +30 -0
- package/src/cmp/FeatureDocs.ts +39 -0
- package/src/cmp/ReadmeRefFeatures.ts +24 -13
- package/src/helpers/applicability.ts +8 -0
- package/src/helpers/canonSpec.ts +301 -0
- package/src/helpers/optspec.ts +180 -0
- package/src/sdkgen.ts +9 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import type { Context, FeatureOptions } from '../../types'
|
|
2
|
+
import type { ProjectNameSDK } from '../../ProjectNameSDK'
|
|
3
|
+
|
|
4
|
+
import { ENTITYSPEC } from '../../Schema'
|
|
5
|
+
|
|
6
|
+
import { BaseFeature } from '../base/BaseFeature'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// Payload validation against the model's own field types.
|
|
10
|
+
//
|
|
11
|
+
// The specs are NOT written here and not written in the model either: every
|
|
12
|
+
// entity field already carries a canonical type sentinel (`$STRING`,
|
|
13
|
+
// `$INTEGER`, the `$ONE` union for an OpenAPI multi-type), which is the same
|
|
14
|
+
// vocabulary struct.validate speaks. The generator maps them once
|
|
15
|
+
// (helpers/canonSpec) and emits `ENTITYSPEC` beside this file, so a field
|
|
16
|
+
// whose type changes in the API spec changes what this feature enforces with
|
|
17
|
+
// no edit anywhere.
|
|
18
|
+
//
|
|
19
|
+
// WHAT IS CHECKED
|
|
20
|
+
// outbound (PreSpec) the payload the caller asked to send, against
|
|
21
|
+
// `spec.op[<opname>]` — the operation's request shape,
|
|
22
|
+
// which is the SAME partiality policy that decides what
|
|
23
|
+
// the generated `<Name>CreateData` type requires.
|
|
24
|
+
// inbound (PreDone) each record the operation returned, against
|
|
25
|
+
// `spec.data` — the entity's own field types.
|
|
26
|
+
//
|
|
27
|
+
// WHAT IS NOT. The model carries no array element types, no nested object
|
|
28
|
+
// schemas, no enums, formats or bounds (see canonSpec's note), so this checks
|
|
29
|
+
// the shape the model knows and nothing more. It is a guard against the
|
|
30
|
+
// mistakes the model CAN see — a number where a string belongs, a required
|
|
31
|
+
// field left out, a misspelled key under `strict` — not a substitute for the
|
|
32
|
+
// server's own validation.
|
|
33
|
+
class ValidateFeature extends BaseFeature {
|
|
34
|
+
version = '0.0.1'
|
|
35
|
+
name = 'validate'
|
|
36
|
+
active = true
|
|
37
|
+
|
|
38
|
+
_client?: ProjectNameSDK
|
|
39
|
+
_options: any = {}
|
|
40
|
+
_spec: Record<string, any> = {}
|
|
41
|
+
|
|
42
|
+
_request = true
|
|
43
|
+
_response = false
|
|
44
|
+
_mode = 'throw'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
init(ctx: Context, options: FeatureOptions): void | Promise<any> {
|
|
48
|
+
this._client = ctx.client
|
|
49
|
+
this._options = options || {}
|
|
50
|
+
this.active = (options as any).active
|
|
51
|
+
|
|
52
|
+
// DEFAULTS ARE APPLIED HERE, not by the option spec. The model's
|
|
53
|
+
// `config.options` documents them and types them; it does not inject
|
|
54
|
+
// them, because each feature entry in the spec is optional and struct
|
|
55
|
+
// fills in nothing through an optional union. So every feature resolves
|
|
56
|
+
// its own — and a `mode` left undefined here once meant `'throw' !==
|
|
57
|
+
// undefined`, which silently turned every rejection into a no-op.
|
|
58
|
+
this._request = false !== this._options.request
|
|
59
|
+
this._response = true === this._options.response
|
|
60
|
+
|
|
61
|
+
// FAIL CLOSED. Only the exact string 'report' selects report mode, so a
|
|
62
|
+
// typo (`mode: 'thow'`) still rejects rather than silently turning
|
|
63
|
+
// enforcement off — the failure nobody would notice. The option spec
|
|
64
|
+
// rejects the typo outright; this is what happens if it ever does not.
|
|
65
|
+
this._mode = 'report' === this._options.mode ? 'report' : 'throw'
|
|
66
|
+
|
|
67
|
+
// `strict` is applied ONCE, here, by rebuilding the spec tree without the
|
|
68
|
+
// `$OPEN` markers — rather than per call, which would clone a spec for
|
|
69
|
+
// every request an SDK ever makes.
|
|
70
|
+
this._spec = true === this._options.strict ? close(ENTITYSPEC) : ENTITYSPEC
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
// Outbound. `makeSpec` short-circuits on an `ctx.out.spec` that is already
|
|
75
|
+
// set, so assigning the error here rejects the operation before the request
|
|
76
|
+
// is built — the same seam rbac uses one stage earlier.
|
|
77
|
+
PreSpec(this: any, ctx: any) {
|
|
78
|
+
if (!this.active || !this._request) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const opname = (ctx.op && ctx.op.name) || ''
|
|
83
|
+
const spec = this._entitySpec(ctx)
|
|
84
|
+
const opspec = spec && spec.op ? spec.op[opname] : null
|
|
85
|
+
|
|
86
|
+
if (null == opspec) {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const errs = this._check(ctx, this._payload(ctx, opname), opspec, 'request')
|
|
91
|
+
if (0 === errs.length || 'report' === this._mode) {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const err = ctx.error('validate_failed',
|
|
96
|
+
'Invalid ' + opname + ' request for entity "' + entname(ctx) + '": ' +
|
|
97
|
+
errs.join('; '))
|
|
98
|
+
ctx.out.spec = err
|
|
99
|
+
return err
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
// Inbound. PreDone rather than PreResult: the records are extracted from
|
|
104
|
+
// the response body by `makeResult`, which runs between the two, so at
|
|
105
|
+
// PreResult there is nothing to check but the envelope.
|
|
106
|
+
//
|
|
107
|
+
// HOOK ORDER MATTERS HERE, and the default order is not the one you want.
|
|
108
|
+
// PreDone hooks fire in feature ADD order, which defaults to `test` first
|
|
109
|
+
// and then names sorted — and `validate` sorts last, after `audit`,
|
|
110
|
+
// `cost`, `debug`, `metrics` and `telemetry`. Those observers therefore
|
|
111
|
+
// record the operation as a success before this hook has looked at it.
|
|
112
|
+
// Activating features as an ORDERED ARRAY fixes it:
|
|
113
|
+
//
|
|
114
|
+
// feature: [{ name: 'validate', active: true, response: true },
|
|
115
|
+
// { name: 'metrics', active: true }]
|
|
116
|
+
//
|
|
117
|
+
// What this feature can fix from here, it does: the result is marked
|
|
118
|
+
// failed and its records are cleared, so the entity absorbs nothing.
|
|
119
|
+
PreDone(this: any, ctx: any) {
|
|
120
|
+
if (!this.active || !this._response) {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const spec = this._entitySpec(ctx)
|
|
125
|
+
if (null == spec || null == spec.data) {
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const resdata = ctx.result && ctx.result.resdata
|
|
130
|
+
if (null == resdata) {
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A list op returns many records and a load returns one; both are checked
|
|
135
|
+
// against the same record spec, because they are the same entity.
|
|
136
|
+
const records = Array.isArray(resdata) ? resdata : [resdata]
|
|
137
|
+
const errs: string[] = []
|
|
138
|
+
for (const record of records) {
|
|
139
|
+
if (null == record) {
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A NON-OBJECT IS A FAILURE, not something to skip. A load that
|
|
144
|
+
// answered `42` where the entity's spec wants a record used to pass
|
|
145
|
+
// this feature silently, which is the one outcome a validator must
|
|
146
|
+
// never produce. struct rejects it with the field it could not find.
|
|
147
|
+
for (const e of this._check(ctx, unwrap(record), spec.data, 'response')) {
|
|
148
|
+
errs.push(e)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (0 === errs.length || 'report' === this._mode) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const err = ctx.error('validate_failed',
|
|
157
|
+
'Invalid response for entity "' + entname(ctx) + '": ' + errs.join('; '))
|
|
158
|
+
|
|
159
|
+
// BOTH, and `ok` is the load-bearing half: `done` returns `resdata`
|
|
160
|
+
// whenever `result.ok` is true and never looks at `err`, so setting the
|
|
161
|
+
// error alone handed the caller the very records that failed the spec.
|
|
162
|
+
ctx.result.ok = false
|
|
163
|
+
ctx.result.err = err
|
|
164
|
+
|
|
165
|
+
// AND THE DATA GOES. The load/update fragments copy `result.resdata`
|
|
166
|
+
// into the entity's own state on any non-null value, BEFORE `done`
|
|
167
|
+
// raises — so rejecting the operation while leaving the records in place
|
|
168
|
+
// left the caller holding an entity populated from a payload this
|
|
169
|
+
// feature had just declared invalid. Clearing it is the only half of
|
|
170
|
+
// that this feature owns; see the note on hook order below.
|
|
171
|
+
ctx.result.resdata = undefined
|
|
172
|
+
|
|
173
|
+
return err
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
// The payload an operation is about to send.
|
|
178
|
+
//
|
|
179
|
+
// TWO SLOTS, AND THE OP PICKS. A body op (create/update/patch) carries the
|
|
180
|
+
// caller's argument in `reqdata` over the entity's `data`; a match op
|
|
181
|
+
// (load/list/remove) carries it in `reqmatch` over `match`. That is what
|
|
182
|
+
// the Entity*Op fragments pass to makeContext and what makePoint reads
|
|
183
|
+
// (`getprop(ctx, 'req' + op.input)`) — so reading `reqdata` for every op
|
|
184
|
+
// checked a `load({ id })` against the entity's STALE stored match and
|
|
185
|
+
// rejected it for the id the caller had just supplied.
|
|
186
|
+
_payload(this: any, ctx: any, opname: string): Record<string, any> {
|
|
187
|
+
const body = 'create' === opname || 'update' === opname || 'patch' === opname
|
|
188
|
+
|
|
189
|
+
const base = body ? ctx.data : ctx.match
|
|
190
|
+
const req = body ? ctx.reqdata : ctx.reqmatch
|
|
191
|
+
|
|
192
|
+
const out: Record<string, any> = { ...(base || {}), ...(req || {}) }
|
|
193
|
+
|
|
194
|
+
// `$action` SELECTS A CUSTOM ENDPOINT; it is not a field of the record.
|
|
195
|
+
// makePoint reads it off this same argument and the request transformer
|
|
196
|
+
// drops it before the body is built, so a spec built from the API's own
|
|
197
|
+
// fields will never name it — and under `strict` every custom-action
|
|
198
|
+
// call would be rejected for the one key that made it reachable.
|
|
199
|
+
delete out.$action
|
|
200
|
+
|
|
201
|
+
return out
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_entitySpec(this: any, ctx: any): any {
|
|
206
|
+
return this._spec[entname(ctx)]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
// One validate call. Errors are COLLECTED, never thrown: struct throws on
|
|
211
|
+
// the first failure unless given an `errs` array, and a caller fixing a
|
|
212
|
+
// payload wants every problem with it, not the first one.
|
|
213
|
+
_check(this: any, ctx: any, data: any, spec: any, direction: string): string[] {
|
|
214
|
+
const struct = ctx.utility.struct
|
|
215
|
+
const errs: string[] = []
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
struct.validate(data, spec, { errs })
|
|
219
|
+
}
|
|
220
|
+
catch (e: any) {
|
|
221
|
+
// A spec this port cannot run at all (rather than a payload that fails
|
|
222
|
+
// it) must not take the operation down with it: report it like any
|
|
223
|
+
// other failure and let `mode` decide.
|
|
224
|
+
errs.push(e && e.message ? e.message : String(e))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (0 < errs.length && 'function' === typeof this._options.onInvalid) {
|
|
228
|
+
try {
|
|
229
|
+
this._options.onInvalid({
|
|
230
|
+
entity: entname(ctx),
|
|
231
|
+
op: (ctx.op && ctx.op.name) || '',
|
|
232
|
+
direction,
|
|
233
|
+
errs,
|
|
234
|
+
data,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
catch (_e) { }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return errs
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
// A RESULT RECORD AS DATA.
|
|
246
|
+
//
|
|
247
|
+
// `makeResult` turns every record of a LIST into an entity instance
|
|
248
|
+
// (`entity.make()` then `ent.data(entry)`), so what reaches PreDone for a
|
|
249
|
+
// list is wrappers, not records — and a wrapper checked against a field spec
|
|
250
|
+
// fails on every required field while its actual data goes unchecked. A load
|
|
251
|
+
// returns the record itself, so this has to handle both.
|
|
252
|
+
function unwrap(record: any): any {
|
|
253
|
+
if (null != record && 'function' === typeof record.data) {
|
|
254
|
+
const data = record.data()
|
|
255
|
+
if (null != data) {
|
|
256
|
+
return data
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return record
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
function entname(ctx: any): string {
|
|
264
|
+
return (ctx.entity && ctx.entity.name) || (ctx.op && ctx.op.entity) || ''
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
// The spec tree with every `$OPEN` marker removed, so an undeclared key is an
|
|
269
|
+
// error rather than a pass. Rebuilt rather than mutated: ENTITYSPEC is a
|
|
270
|
+
// module constant shared by every client in the process.
|
|
271
|
+
function close(node: any): any {
|
|
272
|
+
if (Array.isArray(node)) {
|
|
273
|
+
return node.map((n: any) => close(n))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (null == node || 'object' !== typeof node) {
|
|
277
|
+
return node
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const out: Record<string, any> = {}
|
|
281
|
+
for (const key of Object.keys(node)) {
|
|
282
|
+
if (OPEN === key) {
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
out[key] = close(node[key])
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return out
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
// Built rather than written, so the backticks cannot be lost in an edit.
|
|
293
|
+
const OPEN = String.fromCharCode(96) + '$OPEN' + String.fromCharCode(96)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
export {
|
|
297
|
+
ValidateFeature
|
|
298
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
import { Context } from '../types'
|
|
3
|
+
import { OPTSPEC } from '../Schema'
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
function makeOptions(ctx: Context) {
|
|
@@ -54,67 +55,19 @@ function makeOptions(ctx: Context) {
|
|
|
54
55
|
let config = ctx.config || {}
|
|
55
56
|
let cfgopts = config.options || {}
|
|
56
57
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
},
|
|
71
|
-
allow: {
|
|
72
|
-
method: 'GET,PUT,POST,PATCH,DELETE,OPTIONS',
|
|
73
|
-
op: 'create,update,load,list,remove,command,direct,graphql'
|
|
74
|
-
},
|
|
75
|
-
entity: {
|
|
76
|
-
'`$CHILD`': {
|
|
77
|
-
'`$OPEN`': true,
|
|
78
|
-
active: false,
|
|
79
|
-
alias: {}
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
|
-
feature: {
|
|
83
|
-
'`$CHILD`': {
|
|
84
|
-
'`$OPEN`': true,
|
|
85
|
-
active: false,
|
|
86
|
-
}
|
|
87
|
-
},
|
|
88
|
-
utility: {},
|
|
89
|
-
// Feature INSTANCES supplied at construction (the station adopt
|
|
90
|
-
// path): consumed by the constructor's featureAdd loop, so they are
|
|
91
|
-
// class instances, not data - `$ANY` accepts them verbatim. Without
|
|
92
|
-
// this entry the seam is dead: the constructor reads
|
|
93
|
-
// options.extend, but validate rejected the key.
|
|
94
|
-
extend: '`$ANY`' as any,
|
|
95
|
-
system: {
|
|
96
|
-
fetch: undefined as any
|
|
97
|
-
},
|
|
98
|
-
test: {
|
|
99
|
-
active: false,
|
|
100
|
-
entity: {
|
|
101
|
-
'`$OPEN`': true,
|
|
102
|
-
}
|
|
103
|
-
},
|
|
104
|
-
clean: {
|
|
105
|
-
keys: 'key,token,id'
|
|
106
|
-
},
|
|
107
|
-
// Server-variable values for a templated base URL (OpenAPI server
|
|
108
|
-
// variables): `{name}` placeholders in `base` are substituted from
|
|
109
|
-
// this map at construction. Spec defaults arrive via the generated
|
|
110
|
-
// Config; user values override them.
|
|
111
|
-
server: {
|
|
112
|
-
'`$CHILD`': ''
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// JavaScript specific option values.
|
|
117
|
-
optspec.system.fetch = opts.system?.fetch || global.fetch
|
|
58
|
+
// THE OPTION SPEC IS GENERATED, NOT WRITTEN HERE.
|
|
59
|
+
//
|
|
60
|
+
// `Spec.OPTSPEC` is built from the model: `main.kit.optspec` for the
|
|
61
|
+
// standard options, plus one entry per feature this target carries, taken
|
|
62
|
+
// from that feature's own `config.options` / `config.optspec`. Editing this
|
|
63
|
+
// file to add an option would put it back where it was — one of twenty
|
|
64
|
+
// hand-maintained copies of a schema nothing cross-checked — so add it to
|
|
65
|
+
// the model instead and every ported target validates it.
|
|
66
|
+
//
|
|
67
|
+
// NOT MUTATED. It is a module-level constant shared by every client this
|
|
68
|
+
// process constructs; the platform default below is applied to the RESULT,
|
|
69
|
+
// never to the spec.
|
|
70
|
+
const optspec = OPTSPEC
|
|
118
71
|
|
|
119
72
|
// Clone the config side before merging: `config` is a module-level
|
|
120
73
|
// singleton in ts/js, and merge would otherwise use its nested maps as
|
|
@@ -124,6 +77,16 @@ function makeOptions(ctx: Context) {
|
|
|
124
77
|
|
|
125
78
|
opts = validate(opts, optspec)
|
|
126
79
|
|
|
80
|
+
// The platform fetch, supplied AFTER validate rather than as a spec
|
|
81
|
+
// default. `system.fetch` is declared `$ANY`, which passes a caller's own
|
|
82
|
+
// fetch through untouched but inserts nothing when the key is absent — and
|
|
83
|
+
// the spec is shared, so writing the default into it (as this did while the
|
|
84
|
+
// spec was a per-call literal) would hand one client's fetch to the next.
|
|
85
|
+
opts.system = opts.system || {}
|
|
86
|
+
if (null == opts.system.fetch) {
|
|
87
|
+
opts.system.fetch = global.fetch
|
|
88
|
+
}
|
|
89
|
+
|
|
127
90
|
// Restore the suppression the optspec default would otherwise erase.
|
|
128
91
|
if (authSuppressed) {
|
|
129
92
|
opts.auth = null
|
|
@@ -377,16 +377,45 @@ describe('secrets', () => {
|
|
|
377
377
|
// Before any op, nothing has been resolved.
|
|
378
378
|
assert.equal(sdk.options().apikey, '')
|
|
379
379
|
|
|
380
|
-
|
|
381
|
-
|
|
380
|
+
// AN OP THAT EXISTS, not `list` on whichever entity comes first.
|
|
381
|
+
//
|
|
382
|
+
// A generated TS entity carries only the ops its model DECLARES, so
|
|
383
|
+
// `list` is absent from an entity declaring `create` alone — and
|
|
384
|
+
// calling it throws before the PreSpec hook can run, failing the
|
|
385
|
+
// assertion below for a reason that has nothing to do with secrets.
|
|
386
|
+
// univec-sdk hit exactly that: its first entity declares `create`
|
|
387
|
+
// only, so this test failed on every run while PreSpec worked
|
|
388
|
+
// perfectly. Any real op exercises the hook; `list` is not special.
|
|
389
|
+
//
|
|
390
|
+
// This file is a TEMPLATE, so no project's op names are known here
|
|
391
|
+
// either. Discover the first callable one rather than assuming.
|
|
392
|
+
let ran = false
|
|
393
|
+
for (const one of names) {
|
|
394
|
+
const acc = one.charAt(0).toUpperCase() + one.slice(1)
|
|
395
|
+
const ent = (sdk as any)[acc]?.()
|
|
396
|
+
if (null == ent) {
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
const opname = ['list', 'load', 'create']
|
|
400
|
+
.find((o: string) => 'function' === typeof (ent as any)[o])
|
|
401
|
+
if (null == opname) {
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
// The op itself may fail (no seeded data, no live API) — irrelevant
|
|
405
|
+
// here. What matters is that the awaited PreSpec hook ran and the
|
|
406
|
+
// credential reached the live options before the spec was built.
|
|
407
|
+
try {
|
|
408
|
+
await (ent as any)[opname]({})
|
|
409
|
+
}
|
|
410
|
+
catch (_err) { }
|
|
411
|
+
ran = true
|
|
412
|
+
break
|
|
413
|
+
}
|
|
382
414
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
try {
|
|
387
|
-
await sdk[accessor]().list()
|
|
415
|
+
if (!ran) {
|
|
416
|
+
// Entities, but not one callable op between them: no PreSpec path.
|
|
417
|
+
return
|
|
388
418
|
}
|
|
389
|
-
catch (_err) { }
|
|
390
419
|
|
|
391
420
|
assert.equal(sdk.options().apikey, 'ENVKEY02',
|
|
392
421
|
'the entity op did not resolve the secret through PreSpec')
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"package": 1
|
|
4
4
|
},
|
|
5
5
|
"name": "@voxgig/sdkgen",
|
|
6
|
-
"version": "4.
|
|
6
|
+
"version": "4.18.0",
|
|
7
7
|
"provides": {
|
|
8
8
|
"target": [
|
|
9
9
|
"c",
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
"streaming",
|
|
50
50
|
"telemetry",
|
|
51
51
|
"test",
|
|
52
|
-
"timeout"
|
|
52
|
+
"timeout",
|
|
53
|
+
"validate"
|
|
53
54
|
]
|
|
54
55
|
}
|
|
55
56
|
}
|
|
@@ -167,6 +167,36 @@ npm run generate
|
|
|
167
167
|
Note: the \`voxgig-sdkgen\` CLI only *scaffolds* (\`target add\` /
|
|
168
168
|
\`feature add\`). Generation itself runs via \`npm run generate\` (backed by
|
|
169
169
|
\`@voxgig/model\`) — there is no \`generate\` CLI subcommand.
|
|
170
|
+
|
|
171
|
+
### Two silent failure modes
|
|
172
|
+
|
|
173
|
+
Generation has two ways of going wrong that **nothing reports**. Neither
|
|
174
|
+
breaks a build or a test, so the only symptom is a tree that disagrees with
|
|
175
|
+
the model — which is easy to commit past.
|
|
176
|
+
|
|
177
|
+
**\`voxgig-model --no-config\` writes a REDUCED model.** The
|
|
178
|
+
\`.model-config\` build is what registers the generator actions, and an SDK
|
|
179
|
+
project loads \`apidef\` and \`sdkgen\` through exactly that mechanism:
|
|
180
|
+
|
|
181
|
+
\`\`\`
|
|
182
|
+
sys: model: action: { apidef: load: 'build/apidef.js', sdkgen: load: 'build/sdkgen.js' }
|
|
183
|
+
sys: model: order: action: 'apidef,sdkgen'
|
|
184
|
+
\`\`\`
|
|
185
|
+
|
|
186
|
+
\`--no-config\` skips it, so those actions never run — and the model build
|
|
187
|
+
still *writes* the model file, now missing whatever they contribute (the
|
|
188
|
+
name case variants, and whole subtrees). A reduced model is a valid model,
|
|
189
|
+
so nothing downstream complains. To inspect the model layer **without side
|
|
190
|
+
effects**, use \`npm run dry-generate\` (\`-y\`, writes nothing). Never
|
|
191
|
+
\`--no-config\` in anything whose output might be committed.
|
|
192
|
+
|
|
193
|
+
**Regeneration never DELETES.** A file the generator has stopped emitting
|
|
194
|
+
stays in the tree, and \`git status\` is silent because it is committed and
|
|
195
|
+
unchanged. Narrowing a feature's plugin selection, or dropping a target, can
|
|
196
|
+
leave whole modules behind that nothing references and no test covers.
|
|
197
|
+
|
|
198
|
+
To find either, the target trees must be deleted and regenerated — a
|
|
199
|
+
regeneration in place cannot see stale output at all.
|
|
170
200
|
`
|
|
171
201
|
}
|
|
172
202
|
|
package/src/cmp/FeatureDocs.ts
CHANGED
|
@@ -20,6 +20,13 @@ type FeatureDoc = {
|
|
|
20
20
|
transport: string
|
|
21
21
|
wraps: boolean
|
|
22
22
|
options: Array<{ name: string, value: string }>
|
|
23
|
+
|
|
24
|
+
// Options the feature accepts but does not default: callbacks, injected
|
|
25
|
+
// clocks, values the caller either supplies or does not. Declared in
|
|
26
|
+
// `config.optspec` because a DEFAULTS map cannot describe an option that
|
|
27
|
+
// has no default — which is why the tables built from `config.options`
|
|
28
|
+
// alone were incomplete, and had to say so.
|
|
29
|
+
extras: Array<{ name: string, type: string }>
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
|
|
@@ -33,6 +40,27 @@ function isWrapping(feat: any): boolean {
|
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
|
|
43
|
+
// A struct.validate sentinel as a reader's word for it: '`$FUNCTION`' ->
|
|
44
|
+
// 'function'. A union renders its members, so netsim's latency reads
|
|
45
|
+
// 'number | map'. Anything unrecognised renders verbatim rather than being
|
|
46
|
+
// dropped — a doc table that silently omits an option is the failure this
|
|
47
|
+
// whole path exists to fix.
|
|
48
|
+
function sentinelName(v: any): string {
|
|
49
|
+
if (Array.isArray(v)) {
|
|
50
|
+
const members = v.slice(1).map((m: any) => sentinelName(m))
|
|
51
|
+
.filter((m: string) => 'one' !== m)
|
|
52
|
+
return 0 === members.length ? 'any' : members.join(' | ')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if ('string' !== typeof v) {
|
|
56
|
+
return 'any'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const bare = v.replace(/[`$]/g, '').trim().toLowerCase()
|
|
60
|
+
return '' === bare ? 'any' : bare
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
36
64
|
function renderValue(v: any): string {
|
|
37
65
|
if (null == v) { return '' }
|
|
38
66
|
if (Array.isArray(v)) { return '[' + v.map((x) => renderValue(x)).join(', ') + ']' }
|
|
@@ -63,6 +91,15 @@ function featureDocs(model: any, target?: any): FeatureDoc[] {
|
|
|
63
91
|
name: k,
|
|
64
92
|
value: renderValue(opts[k]),
|
|
65
93
|
}))
|
|
94
|
+
|
|
95
|
+
const extra = (f.config && f.config.optspec) || {}
|
|
96
|
+
const extras = Object.keys(extra)
|
|
97
|
+
// A name in both is documented by its DEFAULT; `config.optspec`
|
|
98
|
+
// only sharpened its type (netsim's `latency`, a number that is
|
|
99
|
+
// also a { min, max } map), and a reader wants the default.
|
|
100
|
+
.filter((k) => null == opts[k])
|
|
101
|
+
.sort()
|
|
102
|
+
.map((k) => ({ name: k, type: sentinelName(extra[k]) }))
|
|
66
103
|
return {
|
|
67
104
|
name: f.name,
|
|
68
105
|
Name: f.Name || f.name,
|
|
@@ -70,6 +107,7 @@ function featureDocs(model: any, target?: any): FeatureDoc[] {
|
|
|
70
107
|
transport: f.transport || 'none',
|
|
71
108
|
wraps: isWrapping(f),
|
|
72
109
|
options,
|
|
110
|
+
extras,
|
|
73
111
|
}
|
|
74
112
|
})
|
|
75
113
|
.sort((a: FeatureDoc, b: FeatureDoc) => a.name.localeCompare(b.name))
|
|
@@ -100,6 +138,7 @@ function honoursActivationOrder(target: any): boolean {
|
|
|
100
138
|
export {
|
|
101
139
|
featureDocs,
|
|
102
140
|
renderValue,
|
|
141
|
+
sentinelName,
|
|
103
142
|
honoursActivationOrder,
|
|
104
143
|
}
|
|
105
144
|
|
|
@@ -78,13 +78,14 @@ ${f.title}.
|
|
|
78
78
|
|
|
79
79
|
`)
|
|
80
80
|
|
|
81
|
-
// THE TABLE IS THE MODEL'S OPTIONS,
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
81
|
+
// THE TABLE IS THE MODEL'S OPTIONS, and the model now carries both
|
|
82
|
+
// halves of them. It used to carry one: `config.options` is a DEFAULTS
|
|
83
|
+
// map, so the options with no default — cost's `sink`, audit's `sink`,
|
|
84
|
+
// every injected clock — could not appear in it, and this table had to
|
|
85
|
+
// admit it was not exhaustive. `config.optspec` declares those with a
|
|
86
|
+
// type instead of a default, and they are listed below the defaults.
|
|
87
|
+
// Same declaration the generated option spec validates against, so a
|
|
88
|
+
// documented option and a validated option cannot be different sets.
|
|
88
89
|
|
|
89
90
|
|
|
90
91
|
if (0 < f.options.length) {
|
|
@@ -98,18 +99,28 @@ ${f.title}.
|
|
|
98
99
|
Content(`
|
|
99
100
|
`)
|
|
100
101
|
}
|
|
101
|
-
else {
|
|
102
|
+
else if (0 === f.extras.length) {
|
|
102
103
|
Content(`\`active\` only — this feature takes no further options.
|
|
103
104
|
|
|
104
105
|
`)
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
if (0 < f.extras.length) {
|
|
109
|
+
Content(`| Option | Type |
|
|
110
|
+
|---|---|
|
|
111
|
+
`)
|
|
112
|
+
for (const o of f.extras) {
|
|
113
|
+
Content(`| \`${o.name}\` | ${o.type} |
|
|
114
|
+
`)
|
|
115
|
+
}
|
|
116
|
+
Content(`
|
|
117
|
+
These take no default: the feature behaves one way when you supply them and
|
|
118
|
+
another when you do not.
|
|
119
|
+
|
|
120
|
+
`)
|
|
121
|
+
}
|
|
111
122
|
|
|
112
|
-
|
|
123
|
+
Content(`**Usage**
|
|
113
124
|
|
|
114
125
|
Set \`feature.${f.name}.active\` to true in the client options${
|
|
115
126
|
0 < f.options.length ?
|
|
@@ -105,6 +105,14 @@ function targetFeatures(model: any, target: any): Record<string, any> {
|
|
|
105
105
|
const TAGS = [
|
|
106
106
|
// A vendored sekreto port lives in this target's feature container.
|
|
107
107
|
'sekreto',
|
|
108
|
+
|
|
109
|
+
// The target emits the generated `Schema` module: the model's option spec
|
|
110
|
+
// and, when a feature asks for them, per-entity struct.validate specs.
|
|
111
|
+
// `validate` needs this, and the porting order is the reason it is a tag
|
|
112
|
+
// rather than an assumption — every target vendors a struct with
|
|
113
|
+
// `validate` in it, but only a target whose Main emits Schema has
|
|
114
|
+
// anything for that function to check against.
|
|
115
|
+
'schema',
|
|
108
116
|
]
|
|
109
117
|
|
|
110
118
|
|