@voxgig/apidef 8.2.3 → 8.4.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/desc.d.ts +2 -0
- package/dist/guide/graphql01.d.ts +3 -2
- package/dist/guide/graphql01.js +48 -2
- package/dist/guide/graphql01.js.map +1 -1
- package/dist/guide/guide.js +65 -0
- package/dist/guide/guide.js.map +1 -1
- package/dist/guide/heuristic01.js +119 -5
- package/dist/guide/heuristic01.js.map +1 -1
- package/dist/model.d.ts +6 -0
- package/dist/transform/args.js +21 -2
- package/dist/transform/args.js.map +1 -1
- package/dist/transform/entity.js +1 -0
- package/dist/transform/entity.js.map +1 -1
- package/dist/transform/field.js +332 -4
- package/dist/transform/field.js.map +1 -1
- package/dist/transform/operation.js +43 -3
- package/dist/transform/operation.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.js.map +1 -1
- package/dist/utility.d.ts +2 -1
- package/dist/utility.js +32 -8
- package/dist/utility.js.map +1 -1
- package/model/apidef.aon +40 -0
- package/model/guide.aon +43 -0
- package/package.json +1 -1
- package/src/desc.ts +6 -0
- package/src/guide/graphql01.ts +61 -3
- package/src/guide/guide.ts +74 -0
- package/src/guide/heuristic01.ts +134 -4
- package/src/model.ts +27 -0
- package/src/transform/args.ts +26 -2
- package/src/transform/entity.ts +1 -0
- package/src/transform/field.ts +373 -4
- package/src/transform/operation.ts +49 -3
- package/src/types.ts +15 -0
- package/src/utility.ts +34 -9
package/src/transform/field.ts
CHANGED
|
@@ -32,7 +32,7 @@ import type {
|
|
|
32
32
|
const fieldTransform: Transform = async function(
|
|
33
33
|
ctx: any,
|
|
34
34
|
): Promise<TransformResult> {
|
|
35
|
-
const { apimodel, def } = ctx
|
|
35
|
+
const { apimodel, def, guide, model } = ctx
|
|
36
36
|
const kit: KitModel = apimodel.main[KIT]
|
|
37
37
|
|
|
38
38
|
let msg = 'field '
|
|
@@ -72,9 +72,101 @@ const fieldTransform: Transform = async function(
|
|
|
72
72
|
// Downstream (test generators, fixture builders) gate id-specific code on
|
|
73
73
|
// this presence so that public read-only APIs without ids don't get
|
|
74
74
|
// bogus id assertions.
|
|
75
|
+
// COMPOSITE FIRST, because a compound key need not come with an `id`.
|
|
76
|
+
//
|
|
77
|
+
// An entity addressed by `{owner}/{repo}` whose response carries only
|
|
78
|
+
// `owner` and `name` has no field literally named `id`, and its adjacent
|
|
79
|
+
// placeholders are left unrenamed so `addressedById` is false too.
|
|
80
|
+
// Neither branch below then ran, so the entity got NO id descriptor and
|
|
81
|
+
// even an explicit `guide.entity.<name>.id.parts` was silently ignored —
|
|
82
|
+
// while the Go port, which initialises a descriptor unconditionally,
|
|
83
|
+
// emitted the composite. The ports disagreed on exactly the shape this
|
|
84
|
+
// feature exists for.
|
|
85
|
+
const gent = guide?.entity?.[ment.name]
|
|
86
|
+
const composite = compositeId(ment, gent)
|
|
87
|
+
|
|
75
88
|
const idField = fields.find((f: ModelField) => 'id' === f.name)
|
|
76
|
-
|
|
77
|
-
|
|
89
|
+
|
|
90
|
+
// A COMPOSITE ID IS A STRING, whatever the API's own `id` field is —
|
|
91
|
+
// AND THE API'S OWN id IS KEPT.
|
|
92
|
+
//
|
|
93
|
+
// github's repo declares `id` as an integer, its global database id,
|
|
94
|
+
// while the composite identity is `owner/repo`. Two facts have to
|
|
95
|
+
// survive: `id` must hold a string, because that is what the joined
|
|
96
|
+
// value is and what every generated type has to store; and the spec's
|
|
97
|
+
// numeric property must not be silently reinterpreted, because a
|
|
98
|
+
// consumer that wants the database id is entitled to it with its own
|
|
99
|
+
// type and format intact.
|
|
100
|
+
//
|
|
101
|
+
// So the API's field MOVES to `<api>_id` rather than being rewritten in
|
|
102
|
+
// place, carrying its type, format and per-op overrides with it, and the
|
|
103
|
+
// entity's `alias.field` map records where it went. Retyping in place
|
|
104
|
+
// (the first attempt) claimed the server's numeric id was a string;
|
|
105
|
+
// leaving it alone made `id.field` name a declaration the runtime value
|
|
106
|
+
// cannot satisfy. Moving it is the only option that lies about neither.
|
|
107
|
+
if (null != composite.parts && null != idField && !scalarStringField(idField)) {
|
|
108
|
+
const idf: any = idField
|
|
109
|
+
const apiname = String((model as any)?.name || 'api')
|
|
110
|
+
const keep = apiname + '_id'
|
|
111
|
+
|
|
112
|
+
if (!fields.some((f: ModelField) => f.name === keep)) {
|
|
113
|
+
// A DEEP COPY, because the move is followed by deletions on the
|
|
114
|
+
// original. A spread shares the `op` object, so clearing the stale
|
|
115
|
+
// per-op `type` off `id` cleared it off the preserved field too —
|
|
116
|
+
// the preservation preserved nothing for exactly the key it was
|
|
117
|
+
// added to keep.
|
|
118
|
+
fields.push(JSON.parse(JSON.stringify({ ...idf, name: keep })) as any)
|
|
119
|
+
|
|
120
|
+
const alias = ((ment as any).alias = (ment as any).alias || {})
|
|
121
|
+
alias.field = alias.field || {}
|
|
122
|
+
alias.field[keep] = 'id'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
idf.type = '`$STRING`'
|
|
126
|
+
// The facts that described the moved type go with it: `format: int64`
|
|
127
|
+
// beside a string, or a per-op `type` override still saying integer,
|
|
128
|
+
// is a model contradicting itself — and the op override is what a
|
|
129
|
+
// generator reads for that op.
|
|
130
|
+
delete idf.format
|
|
131
|
+
for (const opname of Object.keys(idf.op || {})) {
|
|
132
|
+
delete idf.op[opname].type
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fields.sort((a: ModelField, b: ModelField) =>
|
|
136
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (null != composite.parts && null == idField) {
|
|
140
|
+
// The FIELD as well as the descriptor, for the reason the branch below
|
|
141
|
+
// documents: a model that declares the descriptor without the field
|
|
142
|
+
// makes the generated type disagree with the generated test.
|
|
143
|
+
fields.push({
|
|
144
|
+
name: 'id',
|
|
145
|
+
type: '`$STRING`',
|
|
146
|
+
req: false,
|
|
147
|
+
} as any)
|
|
148
|
+
fields.sort((a: ModelField, b: ModelField) =>
|
|
149
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const singleKey = (composite as any).single
|
|
153
|
+
delete (composite as any).single
|
|
154
|
+
|
|
155
|
+
if (null == idField && null != singleKey && null == composite.parts) {
|
|
156
|
+
// The guide disabled composite; the terminal parameter is the key, and
|
|
157
|
+
// the entity needs the field to carry it for the same reason the
|
|
158
|
+
// composite branch above does.
|
|
159
|
+
fields.push({
|
|
160
|
+
name: 'id',
|
|
161
|
+
type: '`$STRING`',
|
|
162
|
+
req: false,
|
|
163
|
+
} as any)
|
|
164
|
+
fields.sort((a: ModelField, b: ModelField) =>
|
|
165
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (idField || null != composite.parts || null != singleKey) {
|
|
169
|
+
ment.id = { name: 'id', field: 'id', ...composite }
|
|
78
170
|
}
|
|
79
171
|
else if (addressedById(ment)) {
|
|
80
172
|
// The FIELD as well as the descriptor. An entity addressed by id has an
|
|
@@ -111,7 +203,7 @@ const fieldTransform: Transform = async function(
|
|
|
111
203
|
// with neither a field nor an id param — the read-only public APIs the
|
|
112
204
|
// rule above was written for — still get no descriptor, so they still
|
|
113
205
|
// get no id assertions.
|
|
114
|
-
ment.id = { name: 'id', field: 'id' }
|
|
206
|
+
ment.id = { name: 'id', field: 'id', ...composite }
|
|
115
207
|
}
|
|
116
208
|
|
|
117
209
|
msg += ment.name + ' '
|
|
@@ -122,6 +214,238 @@ const fieldTransform: Transform = async function(
|
|
|
122
214
|
|
|
123
215
|
|
|
124
216
|
|
|
217
|
+
// The separator that joins a composite id into one string.
|
|
218
|
+
//
|
|
219
|
+
// A forward slash cannot occur inside a single path segment — a raw `/`
|
|
220
|
+
// would end the segment, and a value that legitimately contains one arrives
|
|
221
|
+
// percent-encoded as `%2F` — so joining on it can never be ambiguous, and
|
|
222
|
+
// splitting on it can never over-split. That is what makes the composite id
|
|
223
|
+
// safe to carry as a single opaque string, which is the property the SDK and
|
|
224
|
+
// Seneca entities are built on.
|
|
225
|
+
const ID_SEP = '/'
|
|
226
|
+
|
|
227
|
+
// The ops that address ONE record, most authoritative first. Only a
|
|
228
|
+
// tie-break: identityParams compares candidates from all of them.
|
|
229
|
+
const ID_OPS = ['load', 'update', 'patch', 'remove']
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
// The parameters that TOGETHER name one record: the trailing run of
|
|
233
|
+
// ADJACENT variable segments on the addressing route.
|
|
234
|
+
//
|
|
235
|
+
// ADJACENCY IS THE WHOLE TEST, and it is what separates a compound key from
|
|
236
|
+
// ordinary parent/child nesting:
|
|
237
|
+
//
|
|
238
|
+
// /repos/{owner}/{repo} -> owner, repo COMPOSITE
|
|
239
|
+
// /api/planet/{planet_id}/moon/{moon_id} -> moon_id single
|
|
240
|
+
// /repos/{owner}/{repo}/pulls/{pull_number} -> pull_number single
|
|
241
|
+
//
|
|
242
|
+
// A literal segment between two variables names a SUB-COLLECTION, so the
|
|
243
|
+
// earlier variable scopes the later one — `planet_id` says which planet's
|
|
244
|
+
// moons, and `moon_id` alone identifies the moon. Two variables with nothing
|
|
245
|
+
// between them address no sub-collection: neither value names anything on
|
|
246
|
+
// its own, and only the pair identifies a repository.
|
|
247
|
+
//
|
|
248
|
+
// Taking every variable on the path instead was tried first and is wrong on
|
|
249
|
+
// most real specs — it made `moon` (planet_id + moon_id), petstore's `order`,
|
|
250
|
+
// `pet` and `user`, and taxonomy's `domain` and `kingdom` all falsely
|
|
251
|
+
// composite, which the apidef-validate goldens caught immediately. Nested
|
|
252
|
+
// resources are the common shape; compound keys are the exception, and
|
|
253
|
+
// adjacency is the thing that actually distinguishes them.
|
|
254
|
+
//
|
|
255
|
+
// Read from the op that names a single record, never from `list`: a
|
|
256
|
+
// collection route's path params are the entity's parents. A point ending in
|
|
257
|
+
// a literal is a verb ON the record (`.../{number}/merge`) and carries the
|
|
258
|
+
// same variables, so it is a fallback rather than a different answer.
|
|
259
|
+
// Walk back from a point's end, collecting variables until a literal stops
|
|
260
|
+
// the run. That literal is the sub-collection boundary; anything before it
|
|
261
|
+
// scopes this record rather than naming it.
|
|
262
|
+
function trailingVars(point: any): string[] {
|
|
263
|
+
const segs = ((point?.segments || []) as any[]).filter((s: any) => null != s)
|
|
264
|
+
const run: string[] = []
|
|
265
|
+
|
|
266
|
+
for (let i = segs.length - 1; 0 <= i; i--) {
|
|
267
|
+
if (null == segs[i].var) {
|
|
268
|
+
break
|
|
269
|
+
}
|
|
270
|
+
run.unshift(String(segs[i].var))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return run
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
function identityParams(ment: ModelEntity): string[] {
|
|
278
|
+
// EVERY ID-BEARING OP AT ONCE, not the first one that offers a candidate.
|
|
279
|
+
//
|
|
280
|
+
// These four ops all address a single record, so all four describe the
|
|
281
|
+
// same identity — but they do not all carry the same routes. gitlab's
|
|
282
|
+
// `project` has `/api/v4/projects/{id}` under `remove` alone, while its
|
|
283
|
+
// `load` carries only sub-resources like
|
|
284
|
+
// `/api/v4/projects/{id}/uploads/{secret}/{filename}`. Returning on the
|
|
285
|
+
// first op with any candidate therefore made a PROJECT identified by
|
|
286
|
+
// `secret/filename`. The op order is now only a tie-break.
|
|
287
|
+
const cands: any[] = []
|
|
288
|
+
|
|
289
|
+
for (let o = 0; o < ID_OPS.length; o++) {
|
|
290
|
+
const mop = (ment as any).op?.[ID_OPS[o]]
|
|
291
|
+
if (null == mop) {
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Action points are verbs dispatched by `$action`, not addresses.
|
|
296
|
+
for (const pt of (mop.points || [])) {
|
|
297
|
+
if (null != pt?.select?.['$action']) {
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
const run = trailingVars(pt)
|
|
301
|
+
if (0 === run.length) {
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
cands.push({
|
|
305
|
+
run,
|
|
306
|
+
// Segments BEFORE the run: how much parent scope the route needs.
|
|
307
|
+
scope: ((pt.segments || []).length - run.length),
|
|
308
|
+
// DOES THE RUN END IN THE RECORD'S OWN KEY? Then it is the
|
|
309
|
+
// record's address and nothing further is needed.
|
|
310
|
+
//
|
|
311
|
+
// This transform RENAMES that parameter to `id`, so a run ending in
|
|
312
|
+
// it is this port's own statement of what identifies the record —
|
|
313
|
+
// and the composite inference must not contradict it.
|
|
314
|
+
// `/gists/{gist_id}` becomes `/gists/{id}` and is a gist;
|
|
315
|
+
// `/gists/{gist_id}/{sha}` is a REVISION of one, and won on key
|
|
316
|
+
// length alone, so a gist came out keyed `gist_id/sha` while the
|
|
317
|
+
// generated SDK's own load match takes the single parameter. The
|
|
318
|
+
// same contradiction gave cloudsmith's repo and vulnerability
|
|
319
|
+
// compound keys their SDKs never address them by.
|
|
320
|
+
//
|
|
321
|
+
// Deliberately narrow: exactly `id` or an unrenamed `<entity>_id`,
|
|
322
|
+
// never any `*_id`. `actor_type/actor_id` IS a compound key, and a
|
|
323
|
+
// looser test breaks it.
|
|
324
|
+
own: 'id' === run[run.length - 1] ||
|
|
325
|
+
(ment as any).name + '_id' === run[run.length - 1],
|
|
326
|
+
order: o,
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// WHICH ROUTE IS THE RECORD'S OWN ADDRESS.
|
|
332
|
+
//
|
|
333
|
+
// An entity gathers every route that reads it, and in a large
|
|
334
|
+
// specification most of those are sub-resources. Three earlier rules were
|
|
335
|
+
// measured against the validation corpus, and each is wrong:
|
|
336
|
+
//
|
|
337
|
+
// The FIRST route listed gave github's `repo` the single part
|
|
338
|
+
// `subject_digest`, from
|
|
339
|
+
// `/repos/{owner}/{repo}/attestations/{subject_digest}` — no compound
|
|
340
|
+
// key at all, for the entity this feature exists for. Invisible on a
|
|
341
|
+
// small spec, where the first item route IS the record's own.
|
|
342
|
+
//
|
|
343
|
+
// The SHORTEST route ending in a variable took cloudsmith's
|
|
344
|
+
// `/vulnerabilities/{owner}/` — a LIST of an owner's vulnerabilities —
|
|
345
|
+
// and cut a four-part key down to `owner`, dropping three more
|
|
346
|
+
// composites. Ending in a variable does not make a route an address.
|
|
347
|
+
//
|
|
348
|
+
// The LONGEST trailing run took
|
|
349
|
+
// `/orgs/{org}/teams/{team_slug}/repos/{owner}/{repo}` and made a TEAM
|
|
350
|
+
// identified by `owner/repo`. A deep sub-resource can carry more
|
|
351
|
+
// adjacent variables than the record's own route does.
|
|
352
|
+
//
|
|
353
|
+
// What separates them is PARENT SCOPE: the record's own route is the
|
|
354
|
+
// least-qualified one that names it, and among equally-qualified routes
|
|
355
|
+
// the one carrying the fullest key. `/repos/{owner}/{repo}` is qualified
|
|
356
|
+
// by one segment and the attestations route by four; `/teams/{team_id}`
|
|
357
|
+
// by one and the org-team-repo route by five; cloudsmith's vulnerability
|
|
358
|
+
// routes are all qualified by one, so the fullest of them wins.
|
|
359
|
+
const best = cands.reduce((b: any, c: any) => {
|
|
360
|
+
if (null == b) {
|
|
361
|
+
return c
|
|
362
|
+
}
|
|
363
|
+
if (c.scope !== b.scope) {
|
|
364
|
+
return c.scope < b.scope ? c : b
|
|
365
|
+
}
|
|
366
|
+
if (c.own !== b.own) {
|
|
367
|
+
return c.own ? c : b
|
|
368
|
+
}
|
|
369
|
+
if (c.run.length !== b.run.length) {
|
|
370
|
+
return b.run.length < c.run.length ? c : b
|
|
371
|
+
}
|
|
372
|
+
return c.order < b.order ? c : b
|
|
373
|
+
}, null)
|
|
374
|
+
|
|
375
|
+
return null == best ? [] : best.run
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Is this model field declared as a string? A composite id is the parts
|
|
379
|
+
// joined, so the field that holds it has to be one.
|
|
380
|
+
function scalarStringField(f: any): boolean {
|
|
381
|
+
return String(f?.type || '').toUpperCase().includes('STRING')
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
// WHICH PARAMETER IS THE RECORD'S OWN KEY, among several that looked
|
|
386
|
+
// adjacent. The same shape apidef's id handling recognises everywhere else:
|
|
387
|
+
//
|
|
388
|
+
// 1. one named exactly `id`
|
|
389
|
+
// 2. `<entity>_id` — the entity's own id, however the path spells it
|
|
390
|
+
// 3. any `*_id` — an id by name
|
|
391
|
+
// 4. failing all that, the terminal parameter
|
|
392
|
+
//
|
|
393
|
+
// Position is the LAST resort, not the first.
|
|
394
|
+
function singleKeyOf(ment: ModelEntity, parts: string[]): string | undefined {
|
|
395
|
+
if (0 === parts.length) {
|
|
396
|
+
return undefined
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return parts.find((p: string) => 'id' === p)
|
|
400
|
+
?? parts.find((p: string) => p === ment.name + '_id')
|
|
401
|
+
?? parts.find((p: string) => p.endsWith('_id'))
|
|
402
|
+
?? parts[parts.length - 1]
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
// The composite half of the id descriptor, or `{}` for the ordinary case.
|
|
407
|
+
//
|
|
408
|
+
// Emitted ONLY for a genuinely composite id (two or more addressing
|
|
409
|
+
// parameters). A single-parameter entity already round-trips through one
|
|
410
|
+
// `id` and gains nothing from carrying a one-element `parts`, so its
|
|
411
|
+
// descriptor is left exactly as it was — no existing model output moves.
|
|
412
|
+
function compositeId(
|
|
413
|
+
ment: ModelEntity,
|
|
414
|
+
gent?: any,
|
|
415
|
+
): { parts?: string[], sep?: string } {
|
|
416
|
+
const gid = gent?.id
|
|
417
|
+
const sep = null != gid?.sep && '' !== String(gid.sep) ? String(gid.sep) : ID_SEP
|
|
418
|
+
|
|
419
|
+
// `composite: false` turns the inference off. A boolean rather than an
|
|
420
|
+
// empty `parts`, because aontu resolves an empty list to nothing and the
|
|
421
|
+
// key would arrive absent — indistinguishable from never having been set.
|
|
422
|
+
if (null != gid && false === gid.composite) {
|
|
423
|
+
// DISABLING COMPOSITE MUST NOT DISABLE THE ID. The correction says these
|
|
424
|
+
// adjacent parameters are not a compound key; it does not say the record
|
|
425
|
+
// has no key. Returning a bare `{}` left an entity whose response has no
|
|
426
|
+
// literal `id` with no descriptor at all — the false positive removed and
|
|
427
|
+
// nothing identifying the real key.
|
|
428
|
+
//
|
|
429
|
+
// WHICH of the adjacent parameters is that key is decided by the same
|
|
430
|
+
// id-finding rules apidef uses elsewhere, not by position. Taking the
|
|
431
|
+
// terminal one picked `archive_format` for
|
|
432
|
+
// `/artifacts/{artifact_id}/{archive_format}` — the modifier, precisely
|
|
433
|
+
// the false positive the correction exists to undo.
|
|
434
|
+
return { single: singleKeyOf(ment, identityParams(ment)) } as any
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (null != gid && null != gid.parts) {
|
|
438
|
+
const given = (gid.parts as any[])
|
|
439
|
+
.filter((p: any) => null != p && '' !== String(p))
|
|
440
|
+
.map((p: any) => String(p))
|
|
441
|
+
return 1 < given.length ? { parts: given, sep } : {}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const parts = identityParams(ment)
|
|
445
|
+
return 1 < parts.length ? { parts, sep } : {}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
|
|
125
449
|
// True when any of the entity's own operation points declares an `id`
|
|
126
450
|
// parameter — i.e. the API addresses this entity by id, whether or not its
|
|
127
451
|
// response schema declares an id field.
|
|
@@ -182,6 +506,34 @@ function resolveOpFields(
|
|
|
182
506
|
}
|
|
183
507
|
}
|
|
184
508
|
|
|
509
|
+
// SPEC FACTS ABOUT THE FIELD, carried through verbatim.
|
|
510
|
+
//
|
|
511
|
+
// These four are declared by OpenAPI on the property and were being
|
|
512
|
+
// dropped on the floor. `readOnly` is the one that matters most: it is
|
|
513
|
+
// the difference between a field a client MAY send and one it may not,
|
|
514
|
+
// and nothing else in the model says which — so every generator has been
|
|
515
|
+
// putting server-assigned fields into the type a caller fills in.
|
|
516
|
+
//
|
|
517
|
+
// ONLY WHEN THE SPEC SAYS SO, and for the booleans only when TRUE. Each
|
|
518
|
+
// defaults to false in OpenAPI, so an absent key and an explicit `false`
|
|
519
|
+
// carry the same information; emitting the false ones would add a key to
|
|
520
|
+
// every field of every model and say nothing. Same discipline as
|
|
521
|
+
// `short`: absent means "the spec did not say", never "apidef dropped
|
|
522
|
+
// it".
|
|
523
|
+
for (const flag of ['readOnly', 'writeOnly', 'deprecated'] as const) {
|
|
524
|
+
if (true === (fielddef as any)[flag]) {
|
|
525
|
+
mfield[flag] = true
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// `format` is an open vocabulary — OpenAPI defines a handful and lets a
|
|
530
|
+
// spec coin its own — so it is carried as the string it is rather than
|
|
531
|
+
// interpreted here. `password` is the one a generator acts on today.
|
|
532
|
+
const ffmt = (fielddef as any).format
|
|
533
|
+
if ('string' === typeof ffmt && '' !== ffmt.trim()) {
|
|
534
|
+
mfield.format = ffmt.trim()
|
|
535
|
+
}
|
|
536
|
+
|
|
185
537
|
// Record an untagged union under this field. The field is already typed
|
|
186
538
|
// openly ($ANY/$ARRAY/$OBJECT) because there is nothing to narrow it to;
|
|
187
539
|
// this says WHY, so the generated docs can explain the open type instead
|
|
@@ -536,6 +888,23 @@ function mergeField(
|
|
|
536
888
|
existingField.short = newField.short
|
|
537
889
|
}
|
|
538
890
|
|
|
891
|
+
// The spec facts merge the same way, and for the same reason: one schema
|
|
892
|
+
// annotates the field and another references it bare, so taking the first
|
|
893
|
+
// declaration in opFieldPrecedence order is what finds the annotation.
|
|
894
|
+
//
|
|
895
|
+
// THE PRECEDENCE ORDER PUTS `load` FIRST, WHICH IS THE SAFE DIRECTION HERE.
|
|
896
|
+
// A field the response schema marks readOnly and a request body also lists
|
|
897
|
+
// is a self-contradictory spec — OpenAPI says a client must not send a
|
|
898
|
+
// readOnly property at all — and this resolves it by believing the
|
|
899
|
+
// restriction rather than the omission. Marking a writable field readOnly
|
|
900
|
+
// costs a caller one field; the other way round sends a value the server
|
|
901
|
+
// rejects.
|
|
902
|
+
for (const flag of ['readOnly', 'writeOnly', 'deprecated', 'format'] as const) {
|
|
903
|
+
if (null == existingField[flag] && null != newField[flag]) {
|
|
904
|
+
(existingField as any)[flag] = newField[flag]
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
539
908
|
return existingField
|
|
540
909
|
}
|
|
541
910
|
|
|
@@ -25,6 +25,18 @@ import type {
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
// The op names the transform resolves. Anything else under a guide path's
|
|
29
|
+
// `op` map is dropped, and an unknown name (a verb such as `merge`, or a
|
|
30
|
+
// typo) is dropped WITH A WARNING: guide.aon is the only correction surface
|
|
31
|
+
// (ADR-002), so a correction that vanishes silently defeats it. A non-CRUD
|
|
32
|
+
// verb is declared as `action: <verb>: {}` beside a CRUD op on the same path.
|
|
33
|
+
const RESOLVED_OPS = ['load', 'list', 'create', 'update', 'remove', 'patch']
|
|
34
|
+
|
|
35
|
+
// Emitted by the heuristic for HEAD and OPTIONS methods; no SDK operation
|
|
36
|
+
// exists for them yet, so they are skipped without a warning.
|
|
37
|
+
const IGNORED_OPS = ['head', 'options', 'OPTIONS']
|
|
38
|
+
|
|
39
|
+
|
|
28
40
|
const operationTransform: Transform = async function(
|
|
29
41
|
ctx: any,
|
|
30
42
|
): Promise<TransformResult> {
|
|
@@ -36,7 +48,7 @@ const operationTransform: Transform = async function(
|
|
|
36
48
|
each(guide.entity, (gent: GuideEntity, entname: string) => {
|
|
37
49
|
if (!guideActive(gent)) return
|
|
38
50
|
|
|
39
|
-
collectOps(gent)
|
|
51
|
+
collectOps(ctx, gent)
|
|
40
52
|
|
|
41
53
|
const opm: ModelOpMap = {
|
|
42
54
|
load: undefined,
|
|
@@ -63,7 +75,7 @@ const operationTransform: Transform = async function(
|
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
|
|
66
|
-
function collectOps(gent: GuideEntity) {
|
|
78
|
+
function collectOps(ctx: any, gent: GuideEntity) {
|
|
67
79
|
; (gent as any).opm$ = (gent as any).opm$ ?? {}
|
|
68
80
|
each((gent as any).paths$, (pathdesc: PathDesc) => {
|
|
69
81
|
each(pathdesc.op, (gop: GuidePathOp, opname: OpName) => {
|
|
@@ -72,6 +84,20 @@ function collectOps(gent: GuideEntity) {
|
|
|
72
84
|
return
|
|
73
85
|
}
|
|
74
86
|
|
|
87
|
+
if (!RESOLVED_OPS.includes(opname)) {
|
|
88
|
+
if (!IGNORED_OPS.includes(opname)) {
|
|
89
|
+
ctx.warn?.({
|
|
90
|
+
note: `Unknown op "${opname}" on entity=${gent.name} path=${pathdesc.orig}` +
|
|
91
|
+
` is dropped: only ${RESOLVED_OPS.join('/')} are resolved.` +
|
|
92
|
+
` Declare a verb as \`action: ${opname}: {}\` beside a CRUD op on that path.`,
|
|
93
|
+
entity: gent.name,
|
|
94
|
+
path: pathdesc.orig,
|
|
95
|
+
op: opname,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
75
101
|
; (gent as any).opm$[opname] = (gent as any).opm$[opname] ?? { paths: [] }
|
|
76
102
|
|
|
77
103
|
const oppathdesc: PathDesc = {
|
|
@@ -80,6 +106,7 @@ function collectOps(gent: GuideEntity) {
|
|
|
80
106
|
rename: pathdesc.rename,
|
|
81
107
|
method: gop.method as any,
|
|
82
108
|
op: gop as any,
|
|
109
|
+
action: pathdesc.action,
|
|
83
110
|
def: pathdesc.def,
|
|
84
111
|
}
|
|
85
112
|
|
|
@@ -126,7 +153,18 @@ function resolvePatch(opm: ModelOpMap, gent: GuideEntity): undefined | ModelOp {
|
|
|
126
153
|
const opdesc = resolveOp('patch', gent)
|
|
127
154
|
|
|
128
155
|
// If patch is actually update, make it update!
|
|
129
|
-
|
|
156
|
+
//
|
|
157
|
+
// That holds when there is no PUT update at all, and equally when every
|
|
158
|
+
// PUT update point is an ACTION: a verb such as GitHub's `merge` borrows
|
|
159
|
+
// the update slot (actions have no slot of their own) but is not the
|
|
160
|
+
// entity's update. Leaving PATCH as `patch` there made the real update
|
|
161
|
+
// unreachable, since no target emits a `patch` method, and routed a plain
|
|
162
|
+
// update() to the verb. The action points join the promoted PATCH, and
|
|
163
|
+
// `$action` selects them at call time.
|
|
164
|
+
if (null != opdesc && (null == opm.update || onlyActionPaths(gent, 'update'))) {
|
|
165
|
+
if (null != opm.update) {
|
|
166
|
+
opdesc.points.push(...opm.update.points)
|
|
167
|
+
}
|
|
130
168
|
opm.update = opdesc
|
|
131
169
|
opm.update.name = 'update'
|
|
132
170
|
}
|
|
@@ -138,6 +176,14 @@ function resolvePatch(opm: ModelOpMap, gent: GuideEntity): undefined | ModelOp {
|
|
|
138
176
|
}
|
|
139
177
|
|
|
140
178
|
|
|
179
|
+
// True when every path collected under the op carries a guide action.
|
|
180
|
+
function onlyActionPaths(gent: GuideEntity, opname: OpName): boolean {
|
|
181
|
+
const paths: PathDesc[] = (gent as any).opm$?.[opname]?.paths ?? []
|
|
182
|
+
return 0 < paths.length &&
|
|
183
|
+
paths.every((p: PathDesc) => 0 < Object.keys(p.action ?? {}).length)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
141
187
|
function resolveOp(opname: OpName, gent: GuideEntity): undefined | ModelOp {
|
|
142
188
|
let mop: undefined | ModelOp = undefined
|
|
143
189
|
let opdesc = (gent as any).opm$[opname]
|
package/src/types.ts
CHANGED
|
@@ -253,6 +253,21 @@ type GuideMetrics = {
|
|
|
253
253
|
|
|
254
254
|
|
|
255
255
|
type GuideEntity = {
|
|
256
|
+
// COMPOSITE IDENTITY CORRECTION, the guide's say over how an entity is
|
|
257
|
+
// addressed. apidef infers a compound key from adjacent path parameters,
|
|
258
|
+
// which is right far more often than not and cannot always be right:
|
|
259
|
+
// `/…/artifacts/{artifact_id}/{archive_format}` reads as composite and is
|
|
260
|
+
// not. Stating it here is the documented correction surface (ADR-002).
|
|
261
|
+
//
|
|
262
|
+
// `parts` names the compound key outright; `composite: false` says these
|
|
263
|
+
// adjacent parameters are not one (the record still has a key); `sep`
|
|
264
|
+
// changes the separator without restating the parts.
|
|
265
|
+
id?: {
|
|
266
|
+
parts?: string[]
|
|
267
|
+
sep?: string
|
|
268
|
+
composite?: boolean
|
|
269
|
+
}
|
|
270
|
+
|
|
256
271
|
name: string
|
|
257
272
|
orig: string
|
|
258
273
|
// `false` drops the entity downstream (transform/entity.ts). Emitted by
|
package/src/utility.ts
CHANGED
|
@@ -1070,6 +1070,37 @@ function canonizeCmpName(orig: string): string {
|
|
|
1070
1070
|
}
|
|
1071
1071
|
|
|
1072
1072
|
|
|
1073
|
+
const FIRST_LETTER_RE = /[a-zA-Z]/
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
// No target language permits an identifier that starts with a digit, so a
|
|
1077
|
+
// name derived from one — a `3dsSession` schema, a `/2fa` path segment, a
|
|
1078
|
+
// `_3DSecure` GraphQL type — is prefixed with an `n`.
|
|
1079
|
+
//
|
|
1080
|
+
// The prefix takes the case of the name it guards: lower for `3ds_session`,
|
|
1081
|
+
// upper for `3DSecure`. That keeps the result inside whatever casing
|
|
1082
|
+
// convention the caller was already working in, so a later PascalCase or
|
|
1083
|
+
// camelCase conversion has nothing to undo. A name with no letter in it at
|
|
1084
|
+
// all (`404`) takes the lower-case prefix.
|
|
1085
|
+
//
|
|
1086
|
+
// This is the ONE place the rule lives. Entity names reach it through
|
|
1087
|
+
// `ensureMinEntityName` and the GraphQL guide's `entityName`; project slugs
|
|
1088
|
+
// through `sanitizeSlug`.
|
|
1089
|
+
//
|
|
1090
|
+
// FIELD names deliberately do NOT come here. A field name is a WIRE
|
|
1091
|
+
// identifier and renaming it makes the SDK read a key the server never sends
|
|
1092
|
+
// — the mistake `canonizeField` exists to document. Targets escape those at
|
|
1093
|
+
// the point of emission instead.
|
|
1094
|
+
function prefixLeadingDigit(s: string): string {
|
|
1095
|
+
if (null == s || '' === s) return s
|
|
1096
|
+
const first = s.charCodeAt(0)
|
|
1097
|
+
if (first < 48 || first > 57) return s
|
|
1098
|
+
const letter = s.match(FIRST_LETTER_RE)
|
|
1099
|
+
const upper = null != letter && letter[0] >= 'A' && letter[0] <= 'Z'
|
|
1100
|
+
return (upper ? 'N' : 'n') + s
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
|
|
1073
1104
|
// Sanitize a raw slug into a clean kebab-case string suitable for
|
|
1074
1105
|
// conversion to a valid JS identifier (via camelify/snakify/etc).
|
|
1075
1106
|
function sanitizeSlug(s: string): string {
|
|
@@ -1098,12 +1129,7 @@ function sanitizeSlug(s: string): string {
|
|
|
1098
1129
|
|
|
1099
1130
|
if (!out) return 'unknown'
|
|
1100
1131
|
|
|
1101
|
-
|
|
1102
|
-
if (/^\d/.test(out)) {
|
|
1103
|
-
out = 'n' + out
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
return out
|
|
1132
|
+
return prefixLeadingDigit(out)
|
|
1107
1133
|
}
|
|
1108
1134
|
|
|
1109
1135
|
|
|
@@ -1172,9 +1198,7 @@ function ensureMinEntityName(
|
|
|
1172
1198
|
padded = truncated || parts[0].substring(0, MAX_ENTITY_NAME_LEN)
|
|
1173
1199
|
}
|
|
1174
1200
|
|
|
1175
|
-
|
|
1176
|
-
padded = 'n' + padded
|
|
1177
|
-
}
|
|
1201
|
+
padded = prefixLeadingDigit(padded)
|
|
1178
1202
|
if (padded.length < MIN_ENTITY_NAME_LEN) {
|
|
1179
1203
|
const padding = 'nt'.substring(0, MIN_ENTITY_NAME_LEN - padded.length)
|
|
1180
1204
|
padded = padded + padding
|
|
@@ -1956,6 +1980,7 @@ export {
|
|
|
1956
1980
|
ensureMinEntityName,
|
|
1957
1981
|
inferFieldType,
|
|
1958
1982
|
normalizeFieldName,
|
|
1983
|
+
prefixLeadingDigit,
|
|
1959
1984
|
debugpath,
|
|
1960
1985
|
findPathsWithPrefix,
|
|
1961
1986
|
writeFileSyncWarn,
|