@voxgig/apidef 8.3.0 → 8.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/guide/guide.js +65 -0
- package/dist/guide/guide.js.map +1 -1
- package/dist/model.d.ts +3 -0
- package/dist/transform/field.js +563 -4
- package/dist/transform/field.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.js.map +1 -1
- package/model/apidef.aon +27 -0
- package/model/guide.aon +42 -0
- package/package.json +1 -1
- package/src/guide/guide.ts +74 -0
- package/src/model.ts +23 -0
- package/src/transform/field.ts +659 -4
- package/src/types.ts +19 -0
package/dist/transform/field.js
CHANGED
|
@@ -7,7 +7,7 @@ const jostraca_1 = require("jostraca");
|
|
|
7
7
|
const utility_1 = require("../utility");
|
|
8
8
|
const types_1 = require("../types");
|
|
9
9
|
const fieldTransform = async function (ctx) {
|
|
10
|
-
const { apimodel, def } = ctx;
|
|
10
|
+
const { apimodel, def, guide, model } = ctx;
|
|
11
11
|
const kit = apimodel.main[types_1.KIT];
|
|
12
12
|
let msg = 'field ';
|
|
13
13
|
const opFieldPrecedence = ['load', 'create', 'update', 'patch', 'list'];
|
|
@@ -39,9 +39,88 @@ const fieldTransform = async function (ctx) {
|
|
|
39
39
|
// Downstream (test generators, fixture builders) gate id-specific code on
|
|
40
40
|
// this presence so that public read-only APIs without ids don't get
|
|
41
41
|
// bogus id assertions.
|
|
42
|
+
// COMPOSITE FIRST, because a compound key need not come with an `id`.
|
|
43
|
+
//
|
|
44
|
+
// An entity addressed by `{owner}/{repo}` whose response carries only
|
|
45
|
+
// `owner` and `name` has no field literally named `id`, and its adjacent
|
|
46
|
+
// placeholders are left unrenamed so `addressedById` is false too.
|
|
47
|
+
// Neither branch below then ran, so the entity got NO id descriptor and
|
|
48
|
+
// even an explicit `guide.entity.<name>.id.parts` was silently ignored —
|
|
49
|
+
// while the Go port, which initialises a descriptor unconditionally,
|
|
50
|
+
// emitted the composite. The ports disagreed on exactly the shape this
|
|
51
|
+
// feature exists for.
|
|
52
|
+
const gent = guide?.entity?.[ment.name];
|
|
53
|
+
const composite = compositeId(ment, gent, def);
|
|
42
54
|
const idField = fields.find((f) => 'id' === f.name);
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
// A COMPOSITE ID IS A STRING, whatever the API's own `id` field is —
|
|
56
|
+
// AND THE API'S OWN id IS KEPT.
|
|
57
|
+
//
|
|
58
|
+
// github's repo declares `id` as an integer, its global database id,
|
|
59
|
+
// while the composite identity is `owner/repo`. Two facts have to
|
|
60
|
+
// survive: `id` must hold a string, because that is what the joined
|
|
61
|
+
// value is and what every generated type has to store; and the spec's
|
|
62
|
+
// numeric property must not be silently reinterpreted, because a
|
|
63
|
+
// consumer that wants the database id is entitled to it with its own
|
|
64
|
+
// type and format intact.
|
|
65
|
+
//
|
|
66
|
+
// So the API's field MOVES to `<api>_id` rather than being rewritten in
|
|
67
|
+
// place, carrying its type, format and per-op overrides with it, and the
|
|
68
|
+
// entity's `alias.field` map records where it went. Retyping in place
|
|
69
|
+
// (the first attempt) claimed the server's numeric id was a string;
|
|
70
|
+
// leaving it alone made `id.field` name a declaration the runtime value
|
|
71
|
+
// cannot satisfy. Moving it is the only option that lies about neither.
|
|
72
|
+
if (null != composite.parts && null != idField && !scalarStringField(idField)) {
|
|
73
|
+
const idf = idField;
|
|
74
|
+
const apiname = String(model?.name || 'api');
|
|
75
|
+
const keep = apiname + '_id';
|
|
76
|
+
if (!fields.some((f) => f.name === keep)) {
|
|
77
|
+
// A DEEP COPY, because the move is followed by deletions on the
|
|
78
|
+
// original. A spread shares the `op` object, so clearing the stale
|
|
79
|
+
// per-op `type` off `id` cleared it off the preserved field too —
|
|
80
|
+
// the preservation preserved nothing for exactly the key it was
|
|
81
|
+
// added to keep.
|
|
82
|
+
fields.push(JSON.parse(JSON.stringify({ ...idf, name: keep })));
|
|
83
|
+
const alias = (ment.alias = ment.alias || {});
|
|
84
|
+
alias.field = alias.field || {};
|
|
85
|
+
alias.field[keep] = 'id';
|
|
86
|
+
}
|
|
87
|
+
idf.type = '`$STRING`';
|
|
88
|
+
// The facts that described the moved type go with it: `format: int64`
|
|
89
|
+
// beside a string, or a per-op `type` override still saying integer,
|
|
90
|
+
// is a model contradicting itself — and the op override is what a
|
|
91
|
+
// generator reads for that op.
|
|
92
|
+
delete idf.format;
|
|
93
|
+
for (const opname of Object.keys(idf.op || {})) {
|
|
94
|
+
delete idf.op[opname].type;
|
|
95
|
+
}
|
|
96
|
+
fields.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
97
|
+
}
|
|
98
|
+
if (null != composite.parts && null == idField) {
|
|
99
|
+
// The FIELD as well as the descriptor, for the reason the branch below
|
|
100
|
+
// documents: a model that declares the descriptor without the field
|
|
101
|
+
// makes the generated type disagree with the generated test.
|
|
102
|
+
fields.push({
|
|
103
|
+
name: 'id',
|
|
104
|
+
type: '`$STRING`',
|
|
105
|
+
req: false,
|
|
106
|
+
});
|
|
107
|
+
fields.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
108
|
+
}
|
|
109
|
+
const singleKey = composite.single;
|
|
110
|
+
delete composite.single;
|
|
111
|
+
if (null == idField && null != singleKey && null == composite.parts) {
|
|
112
|
+
// The guide disabled composite; the terminal parameter is the key, and
|
|
113
|
+
// the entity needs the field to carry it for the same reason the
|
|
114
|
+
// composite branch above does.
|
|
115
|
+
fields.push({
|
|
116
|
+
name: 'id',
|
|
117
|
+
type: '`$STRING`',
|
|
118
|
+
req: false,
|
|
119
|
+
});
|
|
120
|
+
fields.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
121
|
+
}
|
|
122
|
+
if (idField || null != composite.parts || null != singleKey) {
|
|
123
|
+
ment.id = { name: 'id', field: 'id', ...composite };
|
|
45
124
|
}
|
|
46
125
|
else if (addressedById(ment)) {
|
|
47
126
|
// The FIELD as well as the descriptor. An entity addressed by id has an
|
|
@@ -76,13 +155,493 @@ const fieldTransform = async function (ctx) {
|
|
|
76
155
|
// with neither a field nor an id param — the read-only public APIs the
|
|
77
156
|
// rule above was written for — still get no descriptor, so they still
|
|
78
157
|
// get no id assertions.
|
|
79
|
-
ment.id = { name: 'id', field: 'id' };
|
|
158
|
+
ment.id = { name: 'id', field: 'id', ...composite };
|
|
80
159
|
}
|
|
81
160
|
msg += ment.name + ' ';
|
|
82
161
|
});
|
|
83
162
|
return { ok: true, msg };
|
|
84
163
|
};
|
|
85
164
|
exports.fieldTransform = fieldTransform;
|
|
165
|
+
// The separator that joins a composite id into one string.
|
|
166
|
+
//
|
|
167
|
+
// A forward slash cannot occur inside a single path segment — a raw `/`
|
|
168
|
+
// would end the segment, and a value that legitimately contains one arrives
|
|
169
|
+
// percent-encoded as `%2F` — so joining on it can never be ambiguous, and
|
|
170
|
+
// splitting on it can never over-split. That is what makes the composite id
|
|
171
|
+
// safe to carry as a single opaque string, which is the property the SDK and
|
|
172
|
+
// Seneca entities are built on.
|
|
173
|
+
const ID_SEP = '/';
|
|
174
|
+
// Subfields that conventionally carry the identifying value of a nested
|
|
175
|
+
// object, in preference order. github's repo `owner` is a user object whose
|
|
176
|
+
// identifier is `login`; other specs use `name`, `slug` or `key`. `id` is
|
|
177
|
+
// last: it is the most common name and the least likely to be the value a
|
|
178
|
+
// PATH parameter takes (a path that wanted an id would say so).
|
|
179
|
+
const NESTED_ID_KEYS = ['login', 'slug', 'name', 'key', 'id'];
|
|
180
|
+
// The ops that address ONE record, most authoritative first. Only a
|
|
181
|
+
// tie-break: identityParams compares candidates from all of them.
|
|
182
|
+
const ID_OPS = ['load', 'update', 'patch', 'remove'];
|
|
183
|
+
// The parameters that TOGETHER name one record: the trailing run of
|
|
184
|
+
// ADJACENT variable segments on the addressing route.
|
|
185
|
+
//
|
|
186
|
+
// ADJACENCY IS THE WHOLE TEST, and it is what separates a compound key from
|
|
187
|
+
// ordinary parent/child nesting:
|
|
188
|
+
//
|
|
189
|
+
// /repos/{owner}/{repo} -> owner, repo COMPOSITE
|
|
190
|
+
// /api/planet/{planet_id}/moon/{moon_id} -> moon_id single
|
|
191
|
+
// /repos/{owner}/{repo}/pulls/{pull_number} -> pull_number single
|
|
192
|
+
//
|
|
193
|
+
// A literal segment between two variables names a SUB-COLLECTION, so the
|
|
194
|
+
// earlier variable scopes the later one — `planet_id` says which planet's
|
|
195
|
+
// moons, and `moon_id` alone identifies the moon. Two variables with nothing
|
|
196
|
+
// between them address no sub-collection: neither value names anything on
|
|
197
|
+
// its own, and only the pair identifies a repository.
|
|
198
|
+
//
|
|
199
|
+
// Taking every variable on the path instead was tried first and is wrong on
|
|
200
|
+
// most real specs — it made `moon` (planet_id + moon_id), petstore's `order`,
|
|
201
|
+
// `pet` and `user`, and taxonomy's `domain` and `kingdom` all falsely
|
|
202
|
+
// composite, which the apidef-validate goldens caught immediately. Nested
|
|
203
|
+
// resources are the common shape; compound keys are the exception, and
|
|
204
|
+
// adjacency is the thing that actually distinguishes them.
|
|
205
|
+
//
|
|
206
|
+
// Read from the op that names a single record, never from `list`: a
|
|
207
|
+
// collection route's path params are the entity's parents. A point ending in
|
|
208
|
+
// a literal is a verb ON the record (`.../{number}/merge`) and carries the
|
|
209
|
+
// same variables, so it is a fallback rather than a different answer.
|
|
210
|
+
// Walk back from a point's end, collecting variables until a literal stops
|
|
211
|
+
// the run. That literal is the sub-collection boundary; anything before it
|
|
212
|
+
// scopes this record rather than naming it.
|
|
213
|
+
function trailingVars(point) {
|
|
214
|
+
const segs = (point?.segments || []).filter((s) => null != s);
|
|
215
|
+
const run = [];
|
|
216
|
+
for (let i = segs.length - 1; 0 <= i; i--) {
|
|
217
|
+
if (null == segs[i].var) {
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
run.unshift(String(segs[i].var));
|
|
221
|
+
}
|
|
222
|
+
return run;
|
|
223
|
+
}
|
|
224
|
+
function identityParams(ment) {
|
|
225
|
+
// EVERY ID-BEARING OP AT ONCE, not the first one that offers a candidate.
|
|
226
|
+
//
|
|
227
|
+
// These four ops all address a single record, so all four describe the
|
|
228
|
+
// same identity — but they do not all carry the same routes. gitlab's
|
|
229
|
+
// `project` has `/api/v4/projects/{id}` under `remove` alone, while its
|
|
230
|
+
// `load` carries only sub-resources like
|
|
231
|
+
// `/api/v4/projects/{id}/uploads/{secret}/{filename}`. Returning on the
|
|
232
|
+
// first op with any candidate therefore made a PROJECT identified by
|
|
233
|
+
// `secret/filename`. The op order is now only a tie-break.
|
|
234
|
+
const cands = [];
|
|
235
|
+
for (let o = 0; o < ID_OPS.length; o++) {
|
|
236
|
+
const mop = ment.op?.[ID_OPS[o]];
|
|
237
|
+
if (null == mop) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
// Action points are verbs dispatched by `$action`, not addresses.
|
|
241
|
+
for (const pt of (mop.points || [])) {
|
|
242
|
+
if (null != pt?.select?.['$action']) {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const run = trailingVars(pt);
|
|
246
|
+
if (0 === run.length) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
cands.push({
|
|
250
|
+
run,
|
|
251
|
+
// Segments BEFORE the run: how much parent scope the route needs.
|
|
252
|
+
scope: ((pt.segments || []).length - run.length),
|
|
253
|
+
// DOES THE RUN END IN THE RECORD'S OWN KEY? Then it is the
|
|
254
|
+
// record's address and nothing further is needed.
|
|
255
|
+
//
|
|
256
|
+
// This transform RENAMES that parameter to `id`, so a run ending in
|
|
257
|
+
// it is this port's own statement of what identifies the record —
|
|
258
|
+
// and the composite inference must not contradict it.
|
|
259
|
+
// `/gists/{gist_id}` becomes `/gists/{id}` and is a gist;
|
|
260
|
+
// `/gists/{gist_id}/{sha}` is a REVISION of one, and won on key
|
|
261
|
+
// length alone, so a gist came out keyed `gist_id/sha` while the
|
|
262
|
+
// generated SDK's own load match takes the single parameter. The
|
|
263
|
+
// same contradiction gave cloudsmith's repo and vulnerability
|
|
264
|
+
// compound keys their SDKs never address them by.
|
|
265
|
+
//
|
|
266
|
+
// Deliberately narrow: exactly `id` or an unrenamed `<entity>_id`,
|
|
267
|
+
// never any `*_id`. `actor_type/actor_id` IS a compound key, and a
|
|
268
|
+
// looser test breaks it.
|
|
269
|
+
own: 'id' === run[run.length - 1] ||
|
|
270
|
+
ment.name + '_id' === run[run.length - 1],
|
|
271
|
+
order: o,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// WHICH ROUTE IS THE RECORD'S OWN ADDRESS.
|
|
276
|
+
//
|
|
277
|
+
// An entity gathers every route that reads it, and in a large
|
|
278
|
+
// specification most of those are sub-resources. Three earlier rules were
|
|
279
|
+
// measured against the validation corpus, and each is wrong:
|
|
280
|
+
//
|
|
281
|
+
// The FIRST route listed gave github's `repo` the single part
|
|
282
|
+
// `subject_digest`, from
|
|
283
|
+
// `/repos/{owner}/{repo}/attestations/{subject_digest}` — no compound
|
|
284
|
+
// key at all, for the entity this feature exists for. Invisible on a
|
|
285
|
+
// small spec, where the first item route IS the record's own.
|
|
286
|
+
//
|
|
287
|
+
// The SHORTEST route ending in a variable took cloudsmith's
|
|
288
|
+
// `/vulnerabilities/{owner}/` — a LIST of an owner's vulnerabilities —
|
|
289
|
+
// and cut a four-part key down to `owner`, dropping three more
|
|
290
|
+
// composites. Ending in a variable does not make a route an address.
|
|
291
|
+
//
|
|
292
|
+
// The LONGEST trailing run took
|
|
293
|
+
// `/orgs/{org}/teams/{team_slug}/repos/{owner}/{repo}` and made a TEAM
|
|
294
|
+
// identified by `owner/repo`. A deep sub-resource can carry more
|
|
295
|
+
// adjacent variables than the record's own route does.
|
|
296
|
+
//
|
|
297
|
+
// What separates them is PARENT SCOPE: the record's own route is the
|
|
298
|
+
// least-qualified one that names it, and among equally-qualified routes
|
|
299
|
+
// the one carrying the fullest key. `/repos/{owner}/{repo}` is qualified
|
|
300
|
+
// by one segment and the attestations route by four; `/teams/{team_id}`
|
|
301
|
+
// by one and the org-team-repo route by five; cloudsmith's vulnerability
|
|
302
|
+
// routes are all qualified by one, so the fullest of them wins.
|
|
303
|
+
const best = cands.reduce((b, c) => {
|
|
304
|
+
if (null == b) {
|
|
305
|
+
return c;
|
|
306
|
+
}
|
|
307
|
+
if (c.scope !== b.scope) {
|
|
308
|
+
return c.scope < b.scope ? c : b;
|
|
309
|
+
}
|
|
310
|
+
if (c.own !== b.own) {
|
|
311
|
+
return c.own ? c : b;
|
|
312
|
+
}
|
|
313
|
+
if (c.run.length !== b.run.length) {
|
|
314
|
+
return b.run.length < c.run.length ? c : b;
|
|
315
|
+
}
|
|
316
|
+
return c.order < b.order ? c : b;
|
|
317
|
+
}, null);
|
|
318
|
+
return null == best ? [] : best.run;
|
|
319
|
+
}
|
|
320
|
+
// THE PROPERTY MAPS A RESPONSE COULD BE DESCRIBING, best first.
|
|
321
|
+
//
|
|
322
|
+
// BOTH SPEC DIALECTS. An OpenAPI 3 response carries its schema under
|
|
323
|
+
// `content['application/json']`; a SWAGGER 2 response carries it directly as
|
|
324
|
+
// `schema`. Reading only the first resolved nothing for every Swagger 2 spec
|
|
325
|
+
// in the validation corpus.
|
|
326
|
+
//
|
|
327
|
+
// JSON ONLY, where there is a choice. An operation may declare several media
|
|
328
|
+
// types with different schemas, and field extraction uses the JSON one — so
|
|
329
|
+
// picking whichever came first in source order could infer a path from an XML
|
|
330
|
+
// or binary schema that the actual JSON record does not have.
|
|
331
|
+
//
|
|
332
|
+
// `allOf` IS EXPANDED, because a response that composes its entity that way
|
|
333
|
+
// has neither `properties` nor `items` of its own. field extraction expands
|
|
334
|
+
// it; not doing so here meant the fields were present while the id could not
|
|
335
|
+
// be reconstructed.
|
|
336
|
+
//
|
|
337
|
+
// ONLY THE ENVELOPE IS DESCENDED, via the same `envelopeProp` rule field
|
|
338
|
+
// extraction uses. Descending every object-valued property instead treats an
|
|
339
|
+
// ordinary nested object as a whole record: for `{ slug, metadata: { tenant } }`
|
|
340
|
+
// addressed by `{tenant}/{slug}`, `tenant` resolved to `tenant` rather than
|
|
341
|
+
// `metadata.tenant` — a confidently wrong path, which is worse than no
|
|
342
|
+
// mapping at all.
|
|
343
|
+
//
|
|
344
|
+
// ACTION POINTS ARE SKIPPED, as `identityParams` skips them: an action's
|
|
345
|
+
// response is a verb's result, not a representation of the entity, so a field
|
|
346
|
+
// that happens to appear there says nothing about what a returned record
|
|
347
|
+
// carries.
|
|
348
|
+
function responseCandidates(ment, def) {
|
|
349
|
+
const out = [];
|
|
350
|
+
const seen = new Set();
|
|
351
|
+
// Every property map this schema describes, expanding allOf.
|
|
352
|
+
const propsOf = (schema) => {
|
|
353
|
+
const node = resolveRef(schema, def);
|
|
354
|
+
if (null == node || seen.has(node)) {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
seen.add(node);
|
|
358
|
+
if (Array.isArray(node.allOf)) {
|
|
359
|
+
return node.allOf.flatMap((member) => propsOf(member));
|
|
360
|
+
}
|
|
361
|
+
if (null != node.properties) {
|
|
362
|
+
return [node.properties];
|
|
363
|
+
}
|
|
364
|
+
// A bare array response: the record is the item.
|
|
365
|
+
const items = resolveRef(node.items, def);
|
|
366
|
+
if (null != items) {
|
|
367
|
+
return propsOf(items);
|
|
368
|
+
}
|
|
369
|
+
return [];
|
|
370
|
+
};
|
|
371
|
+
const add = (schema, opname) => {
|
|
372
|
+
for (const props of propsOf(schema)) {
|
|
373
|
+
out.push(props);
|
|
374
|
+
// One level in, but ONLY through the envelope property.
|
|
375
|
+
const envelope = (0, utility_1.envelopeProp)(props, opname);
|
|
376
|
+
if (null == envelope) {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const inner = resolveRef(props[envelope], def);
|
|
380
|
+
if (null == inner) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
for (const innerProps of propsOf(inner)) {
|
|
384
|
+
out.push(innerProps);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
for (const opname of ['load', 'list', 'update', 'create']) {
|
|
389
|
+
const mop = ment.op?.[opname];
|
|
390
|
+
for (const mpoint of (mop?.points || [])) {
|
|
391
|
+
// An action point's response is not the entity.
|
|
392
|
+
if (null != mpoint?.select?.['$action']) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const path = (def?.paths || {})[mpoint?.orig];
|
|
396
|
+
const method = String(mpoint?.method || '').toLowerCase();
|
|
397
|
+
const responses = path?.[method]?.responses || {};
|
|
398
|
+
for (const code of Object.keys(responses)) {
|
|
399
|
+
if (!/^2/.test(code)) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const resdef = responses[code] || {};
|
|
403
|
+
const content = resdef.content || {};
|
|
404
|
+
const ctypes = Object.keys(content);
|
|
405
|
+
// Prefer JSON; fall back to whatever single type is offered.
|
|
406
|
+
const json = ctypes.find((c) => c.includes('json'));
|
|
407
|
+
if (null != json) {
|
|
408
|
+
add(content[json]?.schema, opname);
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
for (const ctype of ctypes) {
|
|
412
|
+
add(content[ctype]?.schema, opname);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// Swagger 2 puts it here.
|
|
416
|
+
add(resdef.schema, opname);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
// A `$ref` followed one hop, or the schema itself. apidef resolves most refs
|
|
423
|
+
// before this stage; this covers the ones that survive on a nested property.
|
|
424
|
+
function resolveRef(schema, def) {
|
|
425
|
+
if (null == schema) {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
const ref = schema.$ref;
|
|
429
|
+
if ('string' !== typeof ref || !ref.startsWith('#/')) {
|
|
430
|
+
return schema;
|
|
431
|
+
}
|
|
432
|
+
let node = def;
|
|
433
|
+
for (const seg of ref.slice(2).split('/')) {
|
|
434
|
+
node = node?.[seg];
|
|
435
|
+
if (null == node) {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return node;
|
|
440
|
+
}
|
|
441
|
+
// The conventional identifying subfield of a property map, or null.
|
|
442
|
+
function conventionalIdKey(props) {
|
|
443
|
+
for (const key of NESTED_ID_KEYS) {
|
|
444
|
+
const p = props[key];
|
|
445
|
+
if (null == p) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const t = String(p.type || '');
|
|
449
|
+
if ('object' !== t && 'array' !== t) {
|
|
450
|
+
return key;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
// Every name a part might be carried under: the model's name for it, plus the
|
|
456
|
+
// original wire names of any path parameter that was renamed to it.
|
|
457
|
+
function partAliases(ment, part) {
|
|
458
|
+
const names = new Set([part]);
|
|
459
|
+
(0, jostraca_1.each)(ment.op, (mop) => {
|
|
460
|
+
(0, jostraca_1.each)(mop?.points, (mpoint) => {
|
|
461
|
+
const rename = mpoint?.rename?.param || {};
|
|
462
|
+
for (const orig of Object.keys(rename)) {
|
|
463
|
+
if (String(rename[orig]) === part) {
|
|
464
|
+
names.add(orig);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
for (const arg of (mpoint?.args?.params || [])) {
|
|
468
|
+
if (null != arg && arg.name === part && null != arg.orig) {
|
|
469
|
+
names.add(String(arg.orig));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
return [...names];
|
|
475
|
+
}
|
|
476
|
+
// Where one part is carried in a given property map, or null.
|
|
477
|
+
//
|
|
478
|
+
// The four rules, in order, each a fact the spec states: a scalar property of
|
|
479
|
+
// that name; the part naming this entity, resolved to `name`; a scalar
|
|
480
|
+
// `<part>_name` / `_login` / `_slug`; or an object property's conventional
|
|
481
|
+
// identifying subfield.
|
|
482
|
+
function resolvePart(ment, part, aliases, props, def) {
|
|
483
|
+
if (null == props) {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
const prop = (name) => resolveRef(props[name], def);
|
|
487
|
+
const scalar = (p) => null != p && 'object' !== String(p.type) && 'array' !== String(p.type) &&
|
|
488
|
+
null == p.properties && null == p.items;
|
|
489
|
+
for (const name of aliases) {
|
|
490
|
+
if (scalar(prop(name))) {
|
|
491
|
+
return name;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (part === ment.name && scalar(prop('name'))) {
|
|
495
|
+
return 'name';
|
|
496
|
+
}
|
|
497
|
+
for (const name of aliases) {
|
|
498
|
+
for (const suffix of ['_name', '_login', '_slug']) {
|
|
499
|
+
if (scalar(prop(name + suffix))) {
|
|
500
|
+
return name + suffix;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
for (const name of aliases) {
|
|
505
|
+
const nested = prop(name);
|
|
506
|
+
if (null != nested?.properties) {
|
|
507
|
+
const sub = conventionalIdKey(nested.properties);
|
|
508
|
+
if (null != sub) {
|
|
509
|
+
return name + '.' + sub;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
// WHERE EACH COMPOSITE PART'S VALUE LIVES IN A RESPONSE.
|
|
516
|
+
//
|
|
517
|
+
// The parts are PATH PARAMETER names; a response names its fields whatever it
|
|
518
|
+
// likes. Resolving one to the other is what lets an SDK put an id on a record
|
|
519
|
+
// the API returned, rather than only address a record whose id it was given.
|
|
520
|
+
//
|
|
521
|
+
// The rules, in order, and each of them is a fact about the spec rather than
|
|
522
|
+
// a guess:
|
|
523
|
+
//
|
|
524
|
+
// 1. a scalar field of exactly that name -> itself
|
|
525
|
+
// 2. the part names this entity, and there is a `name` -> `name`
|
|
526
|
+
// (`/repos/{owner}/{repo}` on entity `repo`, whose response calls the
|
|
527
|
+
// repository `name`)
|
|
528
|
+
// 3. a scalar `<part>_name` / `<part>_login` / `<part>_slug`
|
|
529
|
+
// 4. an OBJECT field of that name -> `<part>.<conventional key>`
|
|
530
|
+
// (`owner` is a user object; the value is `owner.login`)
|
|
531
|
+
//
|
|
532
|
+
// A part none of these resolve is left OUT. Downstream then knows the id
|
|
533
|
+
// cannot be rebuilt for that entity and can say so, which is better than a
|
|
534
|
+
// confidently wrong id on a real record. guide.aon can state it instead.
|
|
535
|
+
function identityFrom(ment, parts, def) {
|
|
536
|
+
// THE RESPONSE SCHEMA IS THE AUTHORITY, not `ment.fields`.
|
|
537
|
+
//
|
|
538
|
+
// `ment.fields` is merged across load, create, update and list, so a part
|
|
539
|
+
// that exists only in a REQUEST BODY appears there too. Resolving against
|
|
540
|
+
// it recorded such a part in `from` as though a returned record carried it,
|
|
541
|
+
// and a consumer then rebuilt an id from a property the response never
|
|
542
|
+
// sends — worse than leaving the part unresolved, which at least says so.
|
|
543
|
+
//
|
|
544
|
+
// Candidate property maps, in order: the response's own properties, then
|
|
545
|
+
// one level into an envelope. A response that wraps the record
|
|
546
|
+
// (`{ item: {...} }`, `{ data: [ {...} ] }`) states the record's fields one
|
|
547
|
+
// level in, and searching only the wrapper found nothing.
|
|
548
|
+
const candidates = responseCandidates(ment, def);
|
|
549
|
+
const out = {};
|
|
550
|
+
for (const part of parts) {
|
|
551
|
+
// THE WIRE NAME AS WELL AS THE MODEL NAME. `identityParams` reads the
|
|
552
|
+
// RENAMED parameter off the path segments, while a response keeps its own
|
|
553
|
+
// casing — so a `tenantKey` renamed to `tenant_key` was looked up under a
|
|
554
|
+
// name the response does not use, and the mapping was dropped for every
|
|
555
|
+
// camel-cased or depluralized parameter.
|
|
556
|
+
const aliases = partAliases(ment, part);
|
|
557
|
+
let found = null;
|
|
558
|
+
for (const props of candidates) {
|
|
559
|
+
found = resolvePart(ment, part, aliases, props, def);
|
|
560
|
+
if (null != found) {
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (null != found) {
|
|
565
|
+
out[part] = found;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return out;
|
|
569
|
+
}
|
|
570
|
+
// Is this model field declared as a string? A composite id is the parts
|
|
571
|
+
// joined, so the field that holds it has to be one.
|
|
572
|
+
function scalarStringField(f) {
|
|
573
|
+
return String(f?.type || '').toUpperCase().includes('STRING');
|
|
574
|
+
}
|
|
575
|
+
// WHICH PARAMETER IS THE RECORD'S OWN KEY, among several that looked
|
|
576
|
+
// adjacent. The same shape apidef's id handling recognises everywhere else:
|
|
577
|
+
//
|
|
578
|
+
// 1. one named exactly `id`
|
|
579
|
+
// 2. `<entity>_id` — the entity's own id, however the path spells it
|
|
580
|
+
// 3. any `*_id` — an id by name
|
|
581
|
+
// 4. failing all that, the terminal parameter
|
|
582
|
+
//
|
|
583
|
+
// Position is the LAST resort, not the first.
|
|
584
|
+
function singleKeyOf(ment, parts) {
|
|
585
|
+
if (0 === parts.length) {
|
|
586
|
+
return undefined;
|
|
587
|
+
}
|
|
588
|
+
return parts.find((p) => 'id' === p)
|
|
589
|
+
?? parts.find((p) => p === ment.name + '_id')
|
|
590
|
+
?? parts.find((p) => p.endsWith('_id'))
|
|
591
|
+
?? parts[parts.length - 1];
|
|
592
|
+
}
|
|
593
|
+
// The composite half of the id descriptor, or `{}` for the ordinary case.
|
|
594
|
+
//
|
|
595
|
+
// Emitted ONLY for a genuinely composite id (two or more addressing
|
|
596
|
+
// parameters). A single-parameter entity already round-trips through one
|
|
597
|
+
// `id` and gains nothing from carrying a one-element `parts`, so its
|
|
598
|
+
// descriptor is left exactly as it was — no existing model output moves.
|
|
599
|
+
function compositeId(ment, gent, def) {
|
|
600
|
+
const gid = gent?.id;
|
|
601
|
+
const sep = null != gid?.sep && '' !== String(gid.sep) ? String(gid.sep) : ID_SEP;
|
|
602
|
+
// `composite: false` turns the inference off. A boolean rather than an
|
|
603
|
+
// empty `parts`, because aontu resolves an empty list to nothing and the
|
|
604
|
+
// key would arrive absent — indistinguishable from never having been set.
|
|
605
|
+
if (null != gid && false === gid.composite) {
|
|
606
|
+
// DISABLING COMPOSITE MUST NOT DISABLE THE ID. The correction says these
|
|
607
|
+
// adjacent parameters are not a compound key; it does not say the record
|
|
608
|
+
// has no key. Returning a bare `{}` left an entity whose response has no
|
|
609
|
+
// literal `id` with no descriptor at all — the false positive removed and
|
|
610
|
+
// nothing identifying the real key.
|
|
611
|
+
//
|
|
612
|
+
// WHICH of the adjacent parameters is that key is decided by the same
|
|
613
|
+
// id-finding rules apidef uses elsewhere, not by position. Taking the
|
|
614
|
+
// terminal one picked `archive_format` for
|
|
615
|
+
// `/artifacts/{artifact_id}/{archive_format}` — the modifier, precisely
|
|
616
|
+
// the false positive the correction exists to undo.
|
|
617
|
+
return { single: singleKeyOf(ment, identityParams(ment)) };
|
|
618
|
+
}
|
|
619
|
+
// `from` STATED IN guide.aon WINS PER PART, so a spec can correct one
|
|
620
|
+
// mapping without restating the others — which matters because the
|
|
621
|
+
// heuristic gets most of them right and the odd one wrong.
|
|
622
|
+
const withFrom = (parts, usesep) => {
|
|
623
|
+
const derived = identityFrom(ment, parts, def);
|
|
624
|
+
const stated = null != gid?.from && 'object' === typeof gid.from ? gid.from : {};
|
|
625
|
+
const from = {};
|
|
626
|
+
for (const part of parts) {
|
|
627
|
+
const say = stated[part];
|
|
628
|
+
const use = null != say && '' !== String(say) ? String(say) : derived[part];
|
|
629
|
+
if (null != use) {
|
|
630
|
+
from[part] = use;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return 0 === Object.keys(from).length ?
|
|
634
|
+
{ parts, sep: usesep } : { parts, sep: usesep, from };
|
|
635
|
+
};
|
|
636
|
+
if (null != gid && null != gid.parts) {
|
|
637
|
+
const given = gid.parts
|
|
638
|
+
.filter((p) => null != p && '' !== String(p))
|
|
639
|
+
.map((p) => String(p));
|
|
640
|
+
return 1 < given.length ? withFrom(given, sep) : {};
|
|
641
|
+
}
|
|
642
|
+
const parts = identityParams(ment);
|
|
643
|
+
return 1 < parts.length ? withFrom(parts, sep) : {};
|
|
644
|
+
}
|
|
86
645
|
// True when any of the entity's own operation points declares an `id`
|
|
87
646
|
// parameter — i.e. the API addresses this entity by id, whether or not its
|
|
88
647
|
// response schema declares an id field.
|