@voxgig/sdkgen-infrapack 0.0.1 → 0.0.4
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.
|
@@ -156,6 +156,42 @@ function requiredKeys(ent: any, opname: string): string[] {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
|
|
159
|
+
// The keys a single-record call actually sends: the op's required keys, plus
|
|
160
|
+
// the record's own key when the match declares it at all. One definition,
|
|
161
|
+
// because the handler emitter and the test emitter disagreeing about it is
|
|
162
|
+
// how a test comes to assert something the generated code never does.
|
|
163
|
+
function addressKeys(ent: any, opname: string): string[] {
|
|
164
|
+
const keys = requiredKeys(ent, opname)
|
|
165
|
+
const parts = idParts(ent)
|
|
166
|
+
|
|
167
|
+
if (0 < parts.length) {
|
|
168
|
+
// EVERY PART, required or not — for the same reason a single record key
|
|
169
|
+
// always travels. github's api_insights_summary_stat is keyed
|
|
170
|
+
// `actor_type/actor_id` and declares both OPTIONAL, because the op also
|
|
171
|
+
// covers routes that take neither; only `min_timestamp` came through as
|
|
172
|
+
// required. So the call sent the timestamp alone, the split id went
|
|
173
|
+
// nowhere, and every read answered with the same record — the composite
|
|
174
|
+
// half of the defect fixed below for single keys.
|
|
175
|
+
const shape = opRequestShape(ent, opname).items
|
|
176
|
+
for (const part of parts) {
|
|
177
|
+
if (!keys.includes(part) &&
|
|
178
|
+
shape.some((it: any) => it.name === part)) {
|
|
179
|
+
keys.push(part)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return keys
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const rk = recordKey(ent)
|
|
186
|
+
if (null != rk && '' !== rk && !keys.includes(rk) &&
|
|
187
|
+
opRequestShape(ent, opname).items.some((it: any) => it.name === rk)) {
|
|
188
|
+
keys.push(rk)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return keys
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
159
195
|
// The key that addresses ONE record.
|
|
160
196
|
//
|
|
161
197
|
// `entityIdField` answers whenever the model declares one, which apidef does
|
|
@@ -176,6 +212,60 @@ function requiredKeys(ent: any, opname: string): string[] {
|
|
|
176
212
|
// record (base_id, table_id, record_id) alphabetizes with table_id last,
|
|
177
213
|
// so the old `params[params.length - 1]` picked the wrong parent as the
|
|
178
214
|
// record's own key. The point's own `parts` still has the true order.
|
|
215
|
+
// The path parameters that TOGETHER name one record, when no single one does.
|
|
216
|
+
//
|
|
217
|
+
// apidef sets `id.parts` for an API that addresses a record by several
|
|
218
|
+
// adjacent path parameters — github needs {owner} AND {repo} to name a
|
|
219
|
+
// repository — and `id.sep` (a slash) joins them into the ONE id a Seneca
|
|
220
|
+
// entity carries. Empty for the ordinary single-key entity, which is most of
|
|
221
|
+
// them, so every caller can branch on `0 < parts.length`.
|
|
222
|
+
function idParts(ent: any): string[] {
|
|
223
|
+
const parts = ent?.id?.parts
|
|
224
|
+
return Array.isArray(parts) && 1 < parts.length ?
|
|
225
|
+
parts.map((p: any) => String(p)) : []
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
function idSep(ent: any): string {
|
|
230
|
+
const sep = ent?.id?.sep
|
|
231
|
+
return null != sep && '' !== String(sep) ? String(sep) : '/'
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
// THE ONE DESCRIPTION OF AN ENTITY'S ID, or null when Seneca's `id` and the
|
|
236
|
+
// API's key already agree and nothing needs translating.
|
|
237
|
+
//
|
|
238
|
+
// It unifies the two cases that used to be handled by separate emitters. A
|
|
239
|
+
// compound key (`{owner}/{repo}`) has several parts; an API that simply calls
|
|
240
|
+
// its key something else (`repo`, `number`) has exactly one. Neither needs a
|
|
241
|
+
// different algorithm — a one-part split is the identity, and a one-part join
|
|
242
|
+
// is the old carry-across — so both come through the same table and the same
|
|
243
|
+
// two functions.
|
|
244
|
+
//
|
|
245
|
+
// `from` is passed through as the model states it: which response field
|
|
246
|
+
// carries each part. apidef derives it and guide.aon can correct it.
|
|
247
|
+
function idSpec(ent: any): { parts: string[], sep: string, from?: Record<string, string> } | null {
|
|
248
|
+
const parts = idParts(ent)
|
|
249
|
+
if (0 < parts.length) {
|
|
250
|
+
const from = ent?.id?.from
|
|
251
|
+
return {
|
|
252
|
+
parts,
|
|
253
|
+
sep: idSep(ent),
|
|
254
|
+
...(null != from && 'object' === typeof from ? { from } : {}),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const rk = recordKey(ent)
|
|
259
|
+
if ('id' === rk) {
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// A single-key entity whose key is not `id`. `from` defaults to the key's
|
|
264
|
+
// own name, which is what the old carry-across read.
|
|
265
|
+
return { parts: [rk], sep: idSep(ent) }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
179
269
|
function recordKey(ent: any): string {
|
|
180
270
|
const idf = entityIdField(ent)
|
|
181
271
|
if (null != idf && '' !== idf) {
|
|
@@ -434,6 +524,77 @@ const Main = cmp(function Main(props: any) {
|
|
|
434
524
|
cls: entityClassName(ent, entityColl),
|
|
435
525
|
ops: entityOps(ent),
|
|
436
526
|
idf: entityIdField(ent),
|
|
527
|
+
// The field this API's routes actually ADDRESS a record by, which is
|
|
528
|
+
// not always `id` and is not always what entityIdField answers (that
|
|
529
|
+
// returns null when the load match has no `id`, leaving recordKey to
|
|
530
|
+
// read the route's last variable segment). The doc and test emitters
|
|
531
|
+
// in Extras need the same answer the handler emitters use, or the
|
|
532
|
+
// seed they build is keyed by a field the routes never look at.
|
|
533
|
+
rk: recordKey(ent),
|
|
534
|
+
// The composite key, when this API addresses a record by several
|
|
535
|
+
// path params at once. The doc and test emitters in Extras need the
|
|
536
|
+
// same answer the handler emitters use: a test that addresses a
|
|
537
|
+
// composite entity the single-key way builds a query its own handler
|
|
538
|
+
// rejects.
|
|
539
|
+
idparts: idParts(ent),
|
|
540
|
+
idsep: idSep(ent),
|
|
541
|
+
// DOES EACH SINGLE-RECORD OP ACTUALLY CARRY THE RECORD'S KEY?
|
|
542
|
+
//
|
|
543
|
+
// Only then can a wrong id miss on a LOAD: github's `interaction`
|
|
544
|
+
// reads `/user/interaction-limits` — a singleton, called as
|
|
545
|
+
// `load({})` — so `load$('no-such-id')` correctly returns the one
|
|
546
|
+
// record there is, and a not-found test against it asserts the
|
|
547
|
+
// opposite of the truth.
|
|
548
|
+
//
|
|
549
|
+
// And only then can a REMOVE delete what a create just made. A
|
|
550
|
+
// tag-bucket entity can have ops addressing different resources
|
|
551
|
+
// entirely: github's `action` is keyed `archive_format` from its
|
|
552
|
+
// download route while its remove takes `hosted_runner_id` and
|
|
553
|
+
// `org_id`, so the remove addressed by parent scope alone — it
|
|
554
|
+
// deleted whichever record the store happened to yield first, which
|
|
555
|
+
// was usually a SEEDED one, and the round-trip failed on the record
|
|
556
|
+
// it had created surviving. Intermittently: the created record's id
|
|
557
|
+
// is random, so where it falls in iteration order decides.
|
|
558
|
+
//
|
|
559
|
+
// Parent keys alone do not distinguish records, which is why this
|
|
560
|
+
// asks for the record's key or every composite part rather than
|
|
561
|
+
// merely for "the route has a parameter".
|
|
562
|
+
// WHICH SINGLE-RECORD OPS ADDRESS A DIFFERENT RESOURCE ENTIRELY.
|
|
563
|
+
//
|
|
564
|
+
// Not merely "cannot address the record": an op that addresses
|
|
565
|
+
// NOTHING is a singleton read, and github's `interaction`
|
|
566
|
+
// (`/user/interaction-limits`) is a real one. This is the other case
|
|
567
|
+
// — the op addresses something, and it is not this record. Every one
|
|
568
|
+
// is a tag-derived entity whose ops were gathered from unrelated
|
|
569
|
+
// routes: `migration`'s remove takes `owner` and `repo` and deletes
|
|
570
|
+
// a repository's migration archive, `user`'s deletes a GPG KEY, and
|
|
571
|
+
// `pull`'s deletes a review COMMENT. The id the caller passed is
|
|
572
|
+
// dropped, and the request goes anyway.
|
|
573
|
+
//
|
|
574
|
+
// Reads are untouched across the whole of github — this is 7 removes
|
|
575
|
+
// and 5 updates, and both are writes.
|
|
576
|
+
idmisaddressed: ['remove', 'update'].reduce(
|
|
577
|
+
(acc: Record<string, boolean>, opname: string) => {
|
|
578
|
+
acc[opname] = null != (ent.op || {})[opname] &&
|
|
579
|
+
0 < addressKeys(ent, opname).length &&
|
|
580
|
+
!(0 < idParts(ent).length ?
|
|
581
|
+
idParts(ent).every((p: string) =>
|
|
582
|
+
addressKeys(ent, opname).includes(p)) :
|
|
583
|
+
addressKeys(ent, opname).includes(recordKey(ent)))
|
|
584
|
+
return acc
|
|
585
|
+
}, {}),
|
|
586
|
+
idaddressed: ['load', 'remove', 'update'].reduce(
|
|
587
|
+
(acc: Record<string, boolean>, opname: string) => {
|
|
588
|
+
const keys = addressKeys(ent, opname)
|
|
589
|
+
const parts = idParts(ent)
|
|
590
|
+
acc[opname] = 0 < parts.length ?
|
|
591
|
+
parts.every((p: string) => keys.includes(p)) :
|
|
592
|
+
keys.includes(recordKey(ent))
|
|
593
|
+
return acc
|
|
594
|
+
}, {}),
|
|
595
|
+
// Where each part is carried in a response. The test emitter needs
|
|
596
|
+
// it to know whether a created record's id can be rebuilt at all.
|
|
597
|
+
idfrom: (ent?.id?.from) || {},
|
|
437
598
|
parents,
|
|
438
599
|
parentOf,
|
|
439
600
|
opParents,
|
|
@@ -699,7 +860,16 @@ const PackageJson = cmp(function PackageJson(props: any) {
|
|
|
699
860
|
},
|
|
700
861
|
// What actually ships. Without `files`, `npm publish` packs the test
|
|
701
862
|
// suite and build output into the tarball.
|
|
702
|
-
|
|
863
|
+
//
|
|
864
|
+
// `doc` IS PART OF THE PACKAGE. The generated README links to
|
|
865
|
+
// doc/tutorial.md, doc/how-to.md, doc/reference.md and
|
|
866
|
+
// doc/explanation.md with relative paths, so omitting it published a
|
|
867
|
+
// README whose every documentation link 404s for anyone reading the
|
|
868
|
+
// installed package rather than the repository. Either the links become
|
|
869
|
+
// absolute repository URLs or the files ship; they ship, because the
|
|
870
|
+
// docs describe the exact version installed and a URL would drift to
|
|
871
|
+
// whatever main says later.
|
|
872
|
+
files: ['dist', 'doc', 'src/**/*.ts', 'LICENSE'],
|
|
703
873
|
engines: { node: '>=24' },
|
|
704
874
|
dependencies: {
|
|
705
875
|
// The SDK this plugin wraps, by its PUBLISHED name and version.
|
|
@@ -892,21 +1062,136 @@ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Option
|
|
|
892
1062
|
// needs translating in both directions, or `load$` requests a record
|
|
893
1063
|
// keyed `undefined` and every entity handed back has no id at all — one
|
|
894
1064
|
// that cannot then be saved or removed.
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1065
|
+
// ID TRANSLATION, ONE ALGORITHM AND A TABLE.
|
|
1066
|
+
//
|
|
1067
|
+
// Seneca entities carry exactly one `id`. Plenty of APIs do not: they
|
|
1068
|
+
// key a record by a differently-named field (`repo`, `number`), or by
|
|
1069
|
+
// SEVERAL path parameters at once with no single one that is the id
|
|
1070
|
+
// (github's `/repos/{owner}/{repo}`). Both need translating in both
|
|
1071
|
+
// directions, or `load$` asks for a record keyed `undefined` and every
|
|
1072
|
+
// record handed back has no id to save or remove it by.
|
|
1073
|
+
//
|
|
1074
|
+
// The LOGIC is the same for every API; only which parameters, which
|
|
1075
|
+
// separator and where they live in a response differ, and those are
|
|
1076
|
+
// model data. So this emits one `splitid`, one `joinid` and a table —
|
|
1077
|
+
// not a bespoke pair of functions per entity, which is what it used to
|
|
1078
|
+
// do and which duplicated the same algorithm N times over.
|
|
1079
|
+
const translated = provider.entities.filter((e: any) => null != idSpec(e.ent))
|
|
1080
|
+
|
|
1081
|
+
if (0 < translated.length) {
|
|
1082
|
+
const rows = translated.map((e: any) => {
|
|
1083
|
+
const spec: any = idSpec(e.ent)
|
|
1084
|
+
const from = null == spec.from ? '' :
|
|
1085
|
+
`, from: { ${Object.keys(spec.from).sort()
|
|
1086
|
+
.map((k: string) => `${jsKey(k)}: '${spec.from[k]}'`).join(', ')} }`
|
|
1087
|
+
return ` ${jsKey(e.name)}: { parts: [${
|
|
1088
|
+
spec.parts.map((p: string) => `'${p}'`).join(', ')}], sep: '${spec.sep}'${from} },`
|
|
1089
|
+
}).join('\n')
|
|
1090
|
+
|
|
1091
|
+
Content(` // HOW EACH ENTITY'S id MAPS TO THE API'S OWN KEYS, from the model.
|
|
1092
|
+
//
|
|
1093
|
+
// \`parts\` the path parameters that address one record, in path order.
|
|
1094
|
+
// One part is the ordinary case: the API just calls its key
|
|
1095
|
+
// something other than \`id\`. Two or more is a compound key,
|
|
1096
|
+
// where no single parameter names the record.
|
|
1097
|
+
// \`sep\` joins the parts into the one id a Seneca entity carries. A
|
|
1098
|
+
// slash cannot occur inside a path segment, so the join is
|
|
1099
|
+
// unambiguous and the split cannot over-split.
|
|
1100
|
+
// \`from\` where each part's value lives in a RESPONSE, as a dotted
|
|
1101
|
+
// path. A path parameter's name is not generally a response
|
|
1102
|
+
// field's name: github returns a repo's owner as an OBJECT
|
|
1103
|
+
// (\`owner.login\`) and its name as \`name\`, never \`repo\`.
|
|
1104
|
+
// A part missing here cannot be read back off a response.
|
|
1105
|
+
const ID_SPEC: Record<string, { parts: string[], sep: string, from?: Record<string, string> }> = {
|
|
1106
|
+
${rows}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
// Read a dotted path out of a record. \`from\` maps a path parameter to
|
|
1111
|
+
// wherever the response actually carries it, and that is sometimes inside
|
|
1112
|
+
// a nested object.
|
|
1113
|
+
function idread(data: any, path: string) {
|
|
1114
|
+
let node: any = data
|
|
1115
|
+
for (const key of path.split('.')) {
|
|
1116
|
+
if (null == node) {
|
|
1117
|
+
return undefined
|
|
1118
|
+
}
|
|
1119
|
+
node = node[key]
|
|
1120
|
+
}
|
|
1121
|
+
return node
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
// The Seneca id, split back into the parameters the API addresses a record
|
|
1126
|
+
// with. Refuses a wrong part count rather than sending a URL built from
|
|
1127
|
+
// whatever the id happened to contain — that would address a different
|
|
1128
|
+
// record, or none, and the 404 would name nothing useful.
|
|
1129
|
+
function splitid(name: string, id: any, what: string) {
|
|
1130
|
+
const spec = ID_SPEC[name]
|
|
1131
|
+
const text = null == id ? '' : String(id)
|
|
1132
|
+
const got = 1 === spec.parts.length ? [text] : text.split(spec.sep)
|
|
1133
|
+
|
|
1134
|
+
if (spec.parts.length !== got.length || got.some((p: string) => '' === p)) {
|
|
1135
|
+
throw new Error(
|
|
1136
|
+
'${provider.pkgName}: ' + name + ' ' + what +
|
|
1137
|
+
": id must be '" + spec.parts.join(spec.sep) + "', got: " + JSON.stringify(id))
|
|
903
1138
|
}
|
|
1139
|
+
|
|
1140
|
+
const out: Record<string, any> = {}
|
|
1141
|
+
spec.parts.forEach((p: string, i: number) => { out[p] = got[i] })
|
|
1142
|
+
return out
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
// The id for a record the API returned.
|
|
1147
|
+
//
|
|
1148
|
+
// \`vals\` are the parameters THIS request addressed it with, and they win:
|
|
1149
|
+
// a response does not always repeat them. Otherwise the parts are read out
|
|
1150
|
+
// of the response through \`from\`, which is what makes a created or listed
|
|
1151
|
+
// record identifiable at all.
|
|
1152
|
+
//
|
|
1153
|
+
// THE ADDRESSING KEY WINS over an \`id\` the response already carries. A
|
|
1154
|
+
// response often has both — github's pull has a global database \`id\` and
|
|
1155
|
+
// a repo-scoped \`number\` — and the unrelated one is no use for addressing
|
|
1156
|
+
// anything. It is kept as \`${provider.lower}_id\` rather than dropped.
|
|
1157
|
+
function joinid(name: string, data: any, vals?: any) {
|
|
1158
|
+
const spec = ID_SPEC[name]
|
|
1159
|
+
if (null == data) {
|
|
1160
|
+
return data
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
let id = null
|
|
1164
|
+
|
|
1165
|
+
if (null != vals) {
|
|
1166
|
+
const got = spec.parts.map((p: string) => vals[p])
|
|
1167
|
+
if (got.every((v: any) => null != v && '' !== String(v))) {
|
|
1168
|
+
id = got.join(spec.sep)
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (null == id) {
|
|
1173
|
+
const got = spec.parts.map((p: string) =>
|
|
1174
|
+
idread(data, (spec.from || {})[p] || p))
|
|
1175
|
+
if (got.every((v: any) =>
|
|
1176
|
+
null != v && 'object' !== typeof v && '' !== String(v))) {
|
|
1177
|
+
id = got.join(spec.sep)
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
if (null != id) {
|
|
1182
|
+
if (null != data.id && String(data.id) !== id &&
|
|
1183
|
+
null == ${jsProp('data', provider.lower + '_id')}) {
|
|
1184
|
+
${jsProp('data', provider.lower + '_id')} = data.id
|
|
1185
|
+
}
|
|
1186
|
+
data.id = id
|
|
1187
|
+
}
|
|
1188
|
+
|
|
904
1189
|
return data
|
|
905
1190
|
}
|
|
906
1191
|
|
|
907
1192
|
|
|
908
1193
|
`)
|
|
909
|
-
}
|
|
1194
|
+
}
|
|
910
1195
|
|
|
911
1196
|
// WHICH SDK OP SERVES EACH `action$`, per entity and per cmd.
|
|
912
1197
|
//
|
|
@@ -958,6 +1243,37 @@ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Option
|
|
|
958
1243
|
// That is the silent drop in another hat: the caller named something the
|
|
959
1244
|
// entity does not have and was not told. An empty map inherits them all,
|
|
960
1245
|
// so a cmd with no actions was the most exposed.
|
|
1246
|
+
// THE CMD THAT WOULD WRITE TO THE WRONG RESOURCE.
|
|
1247
|
+
//
|
|
1248
|
+
// Some entities gather their ops from unrelated routes, and then a cmd's
|
|
1249
|
+
// only route addresses something that is not this record: \`migration\`'s
|
|
1250
|
+
// remove deletes a repository's migration ARCHIVE, \`user\`'s deletes a GPG
|
|
1251
|
+
// KEY, \`pull\`'s deletes a review COMMENT. The id the caller passed is not
|
|
1252
|
+
// in the request at all.
|
|
1253
|
+
//
|
|
1254
|
+
// Such a cmd is refused rather than sent. A caller asking to remove one
|
|
1255
|
+
// record must not have a different resource deleted instead, and a
|
|
1256
|
+
// successful-looking reply is the worst possible answer. Where the real
|
|
1257
|
+
// operation exists it is reachable by name, through \`action$\`.
|
|
1258
|
+
function misaddressed(
|
|
1259
|
+
entname: string, cmd: string, key: string, addresses: string[]
|
|
1260
|
+
) {
|
|
1261
|
+
const own = Object.prototype.hasOwnProperty
|
|
1262
|
+
const ents: any = own.call(ACTIONS, entname) ? ACTIONS[entname] : {}
|
|
1263
|
+
const acts = Object.keys(own.call(ents, cmd) ? ents[cmd] : {}).sort()
|
|
1264
|
+
|
|
1265
|
+
throw new Error(
|
|
1266
|
+
'${provider.pkgName}: ' + entname + ' ' + cmd +
|
|
1267
|
+
': this API has no ' + cmd + ' route for one ' + entname +
|
|
1268
|
+
'. Its only ' + cmd + ' route addresses ' + addresses.join(', ') +
|
|
1269
|
+
', not ' + key + ', so the id would be ignored and a different record ' +
|
|
1270
|
+
'changed. ' +
|
|
1271
|
+
(0 < acts.length ?
|
|
1272
|
+
'Name the operation with action\$ instead: ' + acts.join(', ') :
|
|
1273
|
+
'No action\$ of this cmd is available either'))
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
|
|
961
1277
|
function actionop(name: string, entname: string, cmd: string) {
|
|
962
1278
|
const own = Object.prototype.hasOwnProperty
|
|
963
1279
|
const ents: any = own.call(ACTIONS, entname) ? ACTIONS[entname] : {}
|
|
@@ -1008,33 +1324,96 @@ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Option
|
|
|
1008
1324
|
// `save` guards the union of create's and update's keys: one action
|
|
1009
1325
|
// serves both and dispatches at runtime, so it cannot know which set
|
|
1010
1326
|
// applies until it has the data.
|
|
1327
|
+
// A COMPOSITE-KEY ENTITY GUARDS NOTHING SEPARATELY. Its parent keys
|
|
1328
|
+
// travel INSIDE the id, so demanding `q.owner` as well would reject
|
|
1329
|
+
// `load$('octocat/hello-world')` — the very call the composite id
|
|
1330
|
+
// exists to allow. splitid() does the checking instead, and refuses
|
|
1331
|
+
// a wrong part count by name.
|
|
1332
|
+
const eparts = idParts(e.ent)
|
|
1333
|
+
// The whole id description for this entity, or null when none is
|
|
1334
|
+
// needed. `out` branches on it rather than on the record key.
|
|
1335
|
+
const espec = idSpec(e.ent)
|
|
1011
1336
|
const guard = (cmd: string, src: string) => {
|
|
1012
1337
|
const keys = 'save' === cmd ?
|
|
1013
1338
|
[...new Set([...(e.opParents.create || []), ...(e.opParents.update || [])])].sort() :
|
|
1014
1339
|
(e.opParents[cmd] || [])
|
|
1015
1340
|
|
|
1016
1341
|
return keys
|
|
1342
|
+
.filter((k: string) => !eparts.includes(k))
|
|
1017
1343
|
.map((k: string) =>
|
|
1018
1344
|
` ${guardName(e, k)}(${jsProp(src, k)}, '${cmd}')\n`)
|
|
1019
1345
|
.join('')
|
|
1020
1346
|
}
|
|
1021
1347
|
|
|
1348
|
+
// THE REFUSAL LINE, for a cmd whose only route addresses a
|
|
1349
|
+
// different resource. Placed AFTER the action branch — an action
|
|
1350
|
+
// route is named explicitly and is exactly how the real operation is
|
|
1351
|
+
// reached — and BEFORE the guards, so the caller is told the cmd
|
|
1352
|
+
// does not exist for this entity rather than being asked for a
|
|
1353
|
+
// parameter that would not have helped.
|
|
1354
|
+
const refuse = (cmd: string) => {
|
|
1355
|
+
if (true !== (e.idmisaddressed || {})[cmd]) {
|
|
1356
|
+
return ''
|
|
1357
|
+
}
|
|
1358
|
+
const key = 0 < eparts.length ? eparts.join(String(e.idsep || '/')) :
|
|
1359
|
+
recordKey(e.ent)
|
|
1360
|
+
const addresses = addressKeys(e.ent, cmd)
|
|
1361
|
+
|
|
1362
|
+
return ` misaddressed('${e.name}', '${cmd}', '${key}', ` +
|
|
1363
|
+
`[${addresses.map((k: string) => `'${k}'`).join(', ')}])
|
|
1364
|
+
`
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1022
1367
|
// Reading the record's own key off the Seneca query, which always
|
|
1023
1368
|
// spells it `id`, and every other required key off its own name.
|
|
1024
1369
|
const rk = recordKey(e.ent)
|
|
1370
|
+
|
|
1371
|
+
// For a composite key the parameters come out of the split, which the
|
|
1372
|
+
// handler binds to `key` before the call. Any required key the id
|
|
1373
|
+
// does NOT carry still comes off the query.
|
|
1374
|
+
// THE RECORD'S OWN KEY IS ALWAYS SENT, whether or not the match
|
|
1375
|
+
// declares it required.
|
|
1376
|
+
//
|
|
1377
|
+
// An op gathers several routes, and a parameter only one of them
|
|
1378
|
+
// uses comes through OPTIONAL: github's IssueLoadMatch has `owner`
|
|
1379
|
+
// and `repo` required but `id` optional, because the op also covers
|
|
1380
|
+
// `/repos/{owner}/{repo}/issues/comments/{comment_id}`. Sending only
|
|
1381
|
+
// the required keys called `Issue().load({owner, repo})` — the id
|
|
1382
|
+
// the caller passed to `load$` went nowhere, so every read of any
|
|
1383
|
+
// issue in that repo answered with the same record and
|
|
1384
|
+
// `load$('no-such-issue')` returned one. Ten entities failed their
|
|
1385
|
+
// not-found test on it, and the ones that "passed" were passing for
|
|
1386
|
+
// the wrong reason. addressKeys is what the test emitter reads too.
|
|
1025
1387
|
const sdkArg = (opname: string) => {
|
|
1026
|
-
const keys =
|
|
1388
|
+
const keys = addressKeys(e.ent, opname)
|
|
1027
1389
|
if (0 === keys.length) {
|
|
1028
1390
|
return '{}'
|
|
1029
1391
|
}
|
|
1392
|
+
if (0 < eparts.length) {
|
|
1393
|
+
return `{ ${keys.map((k: string) => eparts.includes(k) ?
|
|
1394
|
+
`${jsKey(k)}: ${jsProp('key', k)}` :
|
|
1395
|
+
`${jsKey(k)}: ${jsProp('q', k)}`).join(', ')} }`
|
|
1396
|
+
}
|
|
1030
1397
|
return `{ ${keys.map((k: string) =>
|
|
1031
1398
|
`${jsKey(k)}: ${jsProp('q', k === rk ? 'id' : k)}`).join(', ')} }`
|
|
1032
1399
|
}
|
|
1033
1400
|
|
|
1401
|
+
// The line that splits the Seneca id into the API's parameters,
|
|
1402
|
+
// emitted only where there is one record to address. `list` has none.
|
|
1403
|
+
// The entity-options argument that carries the path parameters for a
|
|
1404
|
+
// write. Empty for an ordinary entity, which needs no such channel.
|
|
1405
|
+
const entArg = 0 === eparts.length ? '' :
|
|
1406
|
+
`null == key ? undefined : { match: key }`
|
|
1407
|
+
|
|
1408
|
+
const splitLine = (cmd: string) => 0 === eparts.length ? '' :
|
|
1409
|
+
` const key = splitid('${e.name}', ${'save' === cmd ? 'data.id' : 'q.id'}, '${cmd}')\n`
|
|
1410
|
+
|
|
1034
1411
|
// The data hop, plus the id alias when the API keys the record by
|
|
1035
|
-
// something other than `id`.
|
|
1036
|
-
|
|
1037
|
-
|
|
1412
|
+
// something other than `id`. A composite entity passes the addressing
|
|
1413
|
+
// values through, because the response may not repeat them.
|
|
1414
|
+
const out = (expr: string, vals?: string) =>
|
|
1415
|
+
null == espec ? `plain(${expr})` :
|
|
1416
|
+
`joinid('${e.name}', plain(${expr})${null == vals ? '' : ', ' + vals})`
|
|
1038
1417
|
|
|
1039
1418
|
// The action branch, emitted for every cmd whether or not this entity
|
|
1040
1419
|
// has actions. `actionop` is what refuses an unknown name, so leaving
|
|
@@ -1084,8 +1463,8 @@ ${actionBranch('list',
|
|
|
1084
1463
|
${actionBranch('load',
|
|
1085
1464
|
` const hit = await ornull(() => this.shared.sdk.${e.acc}()[op$](actionq(msg.q, '${rk}', action$)))
|
|
1086
1465
|
return null == hit ? null : entize(${out('hit')})
|
|
1087
|
-
`)}${guard('load', 'q')} const res = await ornull(() => this.shared.sdk.${e.acc}().load(${sdkArg('load')}))
|
|
1088
|
-
return null == res ? null : entize(${out('res')})
|
|
1466
|
+
`)}${guard('load', 'q')}${splitLine('load')} const res = await ornull(() => this.shared.sdk.${e.acc}().load(${sdkArg('load')}))
|
|
1467
|
+
return null == res ? null : entize(${out('res', 0 < eparts.length ? 'key' : undefined)})
|
|
1089
1468
|
}
|
|
1090
1469
|
|
|
1091
1470
|
`)
|
|
@@ -1099,22 +1478,94 @@ ${actionBranch('load',
|
|
|
1099
1478
|
// entity's data, so its id lives at `id` whatever the API calls it —
|
|
1100
1479
|
// dispatching on the API's key sent every save to `create`, leaving
|
|
1101
1480
|
// update unreachable.
|
|
1481
|
+
// A MISADDRESSED UPDATE IS REFUSED, BUT ONLY ON THE UPDATE LEG. A
|
|
1482
|
+
// create needs no record key — the API assigns one — so an entity
|
|
1483
|
+
// whose update route addresses a different resource can still be
|
|
1484
|
+
// created. Dispatch is on `data.id`, so the refusal goes exactly
|
|
1485
|
+
// where the update would have.
|
|
1486
|
+
const refuseUpdate = true === (e.idmisaddressed || {}).update ?
|
|
1487
|
+
refuse('update') : ''
|
|
1488
|
+
|
|
1102
1489
|
const body = hasCreate && hasUpdate
|
|
1103
|
-
? ` const res = null == data.id
|
|
1104
|
-
? await sdk.${e.acc}().create(data)
|
|
1105
|
-
: await sdk.${e.acc}().update(data)`
|
|
1490
|
+
? (('' === refuseUpdate) ? ` const res = null == data.id
|
|
1491
|
+
? await sdk.${e.acc}(${entArg}).create(data)
|
|
1492
|
+
: await sdk.${e.acc}(${entArg}).update(data)` : ` if (null != data.id) {
|
|
1493
|
+
${refuseUpdate.replace(/\n$/, '')}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
const res = await sdk.${e.acc}(${entArg}).create(data)`)
|
|
1106
1497
|
: hasCreate
|
|
1107
|
-
? ` const res = await sdk.${e.acc}().create(data)`
|
|
1108
|
-
:
|
|
1498
|
+
? ` const res = await sdk.${e.acc}(${entArg}).create(data)`
|
|
1499
|
+
: `${refuseUpdate} const res = await sdk.${e.acc}(${entArg}).update(data)`
|
|
1109
1500
|
|
|
1110
1501
|
// ... and hand the API back its own key, which Seneca does not know
|
|
1111
1502
|
// to send.
|
|
1112
|
-
|
|
1503
|
+
//
|
|
1504
|
+
// A COMPOSITE KEY IS UNPACKED ONTO THE DATA. The write goes out as
|
|
1505
|
+
// a body plus path parameters, and the SDK reads those parameters
|
|
1506
|
+
// off the same object — so the parts have to be present under
|
|
1507
|
+
// their own names, not fused into `id`. On a create there is no id
|
|
1508
|
+
// yet and the caller supplies the parts directly, which is why this
|
|
1509
|
+
// only runs when an id is there.
|
|
1510
|
+
// THE COMPOSITE SPLIT CANNOT LIVE HERE, before the action branch,
|
|
1511
|
+
// even though that is where the single-key alias sits. The alias
|
|
1512
|
+
// only ever assigns; the split THROWS on an id that is not all its
|
|
1513
|
+
// parts, and an `action$` call is entitled to an id shaped however
|
|
1514
|
+
// that action's own route wants. Running it first turned
|
|
1515
|
+
// `action$: 'no_such_action'` into an id complaint, hiding the
|
|
1516
|
+
// error the caller needed. It is emitted after the action branch
|
|
1517
|
+
// instead — see compositeSave below, spliced where the parent
|
|
1518
|
+
// guards go, which is exactly the position the component already
|
|
1519
|
+
// documents as "after the action branch".
|
|
1520
|
+
const compositeSave = 0 < eparts.length ? `
|
|
1521
|
+
// Seneca carries this ${e.name}'s key as one \`id\`; the API addresses
|
|
1522
|
+
// the record by ${eparts.map((p: string) => '`' + p + '`').join(' and ')}.
|
|
1523
|
+
//
|
|
1524
|
+
// THE PARTS GO IN THE ENTITY MATCH, NOT ONTO THE DATA. They are path
|
|
1525
|
+
// parameters, and the data is the request body. Writing them onto the
|
|
1526
|
+
// data is how the flat \`${eparts[0]}\` the URL needs came to displace
|
|
1527
|
+
// whatever the response carries under that name — for github's repo an
|
|
1528
|
+
// \`owner\` OBJECT, so a saved record lost the field that identifies
|
|
1529
|
+
// it. The SDK resolves a path parameter from the match ahead of the
|
|
1530
|
+
// body, so passing it here leaves the body exactly as the caller meant
|
|
1531
|
+
// it.
|
|
1532
|
+
//
|
|
1533
|
+
// \`key\` STAYS NULL ON A CREATE: there is no id yet, the API assigns
|
|
1534
|
+
// the record, and the id is rebuilt from the response instead.
|
|
1535
|
+
let key = null
|
|
1536
|
+
if (null != data.id) {
|
|
1537
|
+
key = splitid('${e.name}', data.id, 'save')
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// \`${provider.lower}_id\` is this provider's own bookkeeping — the
|
|
1541
|
+
// API's unrelated \`id\`, parked by joinid() so it is not lost. It is
|
|
1542
|
+
// not a field of the API's write schema, so it must not travel in the
|
|
1543
|
+
// request body.
|
|
1544
|
+
delete ${jsProp('data', provider.lower + '_id')}
|
|
1545
|
+
|
|
1546
|
+
// AND NEITHER DOES THE JOINED \`id\`. It is Seneca's key for this
|
|
1547
|
+
// record, not the API's: a composite ${e.name} is addressed by
|
|
1548
|
+
// \`${eparts.join('\` and \`')}\`, which travel as path parameters in
|
|
1549
|
+
// the match above. Leaving it on the body sent \`owner0/repo0\` as a
|
|
1550
|
+
// field the write schema has no place for — and the offline transport,
|
|
1551
|
+
// which matches a request against a stored record, then looked for a
|
|
1552
|
+
// record whose own \`id\` was that joined string and found none.
|
|
1553
|
+
delete data.id
|
|
1554
|
+
` : ''
|
|
1555
|
+
|
|
1556
|
+
const alias = 0 < eparts.length ? '' : 'id' === rk ? '' :
|
|
1113
1557
|
`
|
|
1114
1558
|
// This API keys a ${e.name} by \`${rk}\`; Seneca carries it as \`id\`.
|
|
1115
1559
|
if (null == ${jsProp('data', rk)} && null != data.id) {
|
|
1116
1560
|
${jsProp('data', rk)} = data.id
|
|
1117
1561
|
}
|
|
1562
|
+
|
|
1563
|
+
// \`${provider.lower}_id\` is this provider's own bookkeeping — the
|
|
1564
|
+
// API's unrelated \`id\`, parked by joinid() so it is not lost. It
|
|
1565
|
+
// is not a field of the API's write schema, so it must not travel in
|
|
1566
|
+
// the request body: a strict API rejects an unknown property, and a
|
|
1567
|
+
// lax one may persist it.
|
|
1568
|
+
delete ${jsProp('data', provider.lower + '_id')}
|
|
1118
1569
|
`
|
|
1119
1570
|
|
|
1120
1571
|
Content(`
|
|
@@ -1130,9 +1581,9 @@ ${actionBranch('save',
|
|
|
1130
1581
|
data.$action = action$
|
|
1131
1582
|
const done = await sdk.${e.acc}()[op$](data)
|
|
1132
1583
|
return entize(${out('done')})
|
|
1133
|
-
`)}${guard('save', 'data')}${body}
|
|
1584
|
+
`)}${guard('save', 'data')}${compositeSave}${body}
|
|
1134
1585
|
|
|
1135
|
-
return entize(${out('res')})
|
|
1586
|
+
return entize(${out('res', 0 < eparts.length ? 'key' : undefined)})
|
|
1136
1587
|
}
|
|
1137
1588
|
|
|
1138
1589
|
`)
|
|
@@ -1160,8 +1611,9 @@ ${actionBranch('save',
|
|
|
1160
1611
|
${actionBranch('remove',
|
|
1161
1612
|
` const gone = await ornull(() => this.shared.sdk.${e.acc}()[op$](actionq(msg.q, '${rk}', action$)))
|
|
1162
1613
|
return null == gone ? null : entize(${out('gone')})
|
|
1163
|
-
`)}${
|
|
1164
|
-
|
|
1614
|
+
`)}${'' !== refuse('remove') ? refuse('remove') :
|
|
1615
|
+
`${guard('remove', 'q')}${splitLine('remove')} await ornull(() => this.shared.sdk.${e.acc}().remove(${sdkArg('remove')}))
|
|
1616
|
+
`} return null
|
|
1165
1617
|
}
|
|
1166
1618
|
|
|
1167
1619
|
`)
|
|
@@ -1181,9 +1633,20 @@ ${actionBranch('remove',
|
|
|
1181
1633
|
const sdkopts: any = Object.assign({}, options.sdk)
|
|
1182
1634
|
${provider.authActive ? `
|
|
1183
1635
|
// The provider convention carries credentials, so honour an \`apikey\`
|
|
1184
|
-
// when one is configured
|
|
1636
|
+
// when one is configured.
|
|
1185
1637
|
const res = await this.post('sys:provider,get:keymap,provider:${provider.lower}')
|
|
1186
|
-
|
|
1638
|
+
|
|
1639
|
+
// ACCEPT \`api\` AS WELL AS \`apikey\`. The older provider convention
|
|
1640
|
+
// named this key \`api\` and read it with
|
|
1641
|
+
// \`sys:provider,get:key,...,key:api\`; the keymap message replaced that,
|
|
1642
|
+
// and the rename was silent. An application still configured as
|
|
1643
|
+
// \`keys: { api: { value: ... } }\` therefore resolved to undefined and
|
|
1644
|
+
// the SDK was constructed with NO credential at all — the request went
|
|
1645
|
+
// out unauthenticated and failed much later as a 401 or a 404 on
|
|
1646
|
+
// anything private, with nothing at startup to point at the cause.
|
|
1647
|
+
// \`apikey\` wins when both are set, so a config that has migrated is
|
|
1648
|
+
// unaffected.
|
|
1649
|
+
const apikey = res?.keymap?.apikey?.value ?? res?.keymap?.api?.value
|
|
1187
1650
|
|
|
1188
1651
|
// Hand the credential to the SDK as \`apikey\`, NOT as an authorization
|
|
1189
1652
|
// HEADER. The SDK's own auth stage owns that header: it reads
|
|
@@ -1196,6 +1659,23 @@ ${provider.authActive ? `
|
|
|
1196
1659
|
if (null != apikey && '' !== apikey) {
|
|
1197
1660
|
sdkopts.apikey = apikey
|
|
1198
1661
|
}
|
|
1662
|
+
|
|
1663
|
+
// AN UNRESOLVED CREDENTIAL IS SAID OUT LOUD. This API declares
|
|
1664
|
+
// authentication, so reaching here with nothing configured means every
|
|
1665
|
+
// call goes out unauthenticated. That is not always wrong — public
|
|
1666
|
+
// read-only endpoints work, at a much lower rate limit — so this warns
|
|
1667
|
+
// rather than throwing, and names both accepted key spellings so a
|
|
1668
|
+
// misnamed key is obvious from one line of log. Silence here is what
|
|
1669
|
+
// made the \`api\` -> \`apikey\` rename above cost a debugging session
|
|
1670
|
+
// instead of a glance.
|
|
1671
|
+
else {
|
|
1672
|
+
this.log.warn({
|
|
1673
|
+
fix: 'unauthenticated',
|
|
1674
|
+
note: 'no ${provider.lower} credential resolved from the keymap ' +
|
|
1675
|
+
'(looked for keys.apikey then keys.api); requests will be sent ' +
|
|
1676
|
+
'unauthenticated and will fail on anything non-public',
|
|
1677
|
+
})
|
|
1678
|
+
}
|
|
1199
1679
|
${provider.authBasic ? `
|
|
1200
1680
|
// Genuine HTTP Basic Auth needs a SECOND credential (the SDK sends
|
|
1201
1681
|
// \`Authorization: Basic base64(apikey:secret)\`) — without it the SDK's
|