@voxgig/apidef 6.5.0 → 7.0.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/apidef.js +39 -2
- package/dist/apidef.js.map +1 -1
- package/dist/guide/graphql01.d.ts +39 -0
- package/dist/guide/graphql01.js +331 -0
- package/dist/guide/graphql01.js.map +1 -0
- package/dist/guide/guide.js +70 -10
- package/dist/guide/guide.js.map +1 -1
- package/dist/guide/heuristic01.js +1 -0
- package/dist/guide/heuristic01.js.map +1 -1
- package/dist/model.d.ts +22 -1
- package/dist/parse/graphql.d.ts +46 -0
- package/dist/parse/graphql.js +205 -0
- package/dist/parse/graphql.js.map +1 -0
- package/dist/parse.js +19 -0
- package/dist/parse.js.map +1 -1
- package/dist/transform/args.js +49 -4
- package/dist/transform/args.js.map +1 -1
- package/dist/transform/entity.js +51 -3
- package/dist/transform/entity.js.map +1 -1
- package/dist/transform/field.js +71 -1
- package/dist/transform/field.js.map +1 -1
- package/dist/transform/graphql.d.ts +6 -0
- package/dist/transform/graphql.js +227 -0
- package/dist/transform/graphql.js.map +1 -0
- package/dist/transform/select.js +17 -2
- package/dist/transform/select.js.map +1 -1
- package/dist/transform/top.js +31 -2
- package/dist/transform/top.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +16 -1
- package/dist/types.js.map +1 -1
- package/dist/utility.d.ts +2 -1
- package/dist/utility.js +52 -4
- package/dist/utility.js.map +1 -1
- package/model/apidef.aontu +23 -0
- package/model/guide.aontu +22 -0
- package/package.json +12 -4
- package/src/apidef.ts +44 -2
- package/src/guide/graphql01.ts +516 -0
- package/src/guide/guide.ts +90 -12
- package/src/guide/heuristic01.ts +1 -0
- package/src/model.ts +46 -1
- package/src/parse/graphql.ts +302 -0
- package/src/parse.ts +23 -0
- package/src/transform/args.ts +54 -5
- package/src/transform/entity.ts +63 -3
- package/src/transform/field.ts +89 -2
- package/src/transform/graphql.ts +311 -0
- package/src/transform/select.ts +19 -2
- package/src/transform/top.ts +31 -2
- package/src/types.ts +40 -0
- package/src/utility.ts +54 -4
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/* Copyright (c) 2024-2026 Voxgig, MIT License */
|
|
2
|
+
|
|
3
|
+
// GraphQL guide strategy: classify schema root fields into entities and
|
|
4
|
+
// operations, the way heuristic01 classifies REST paths.
|
|
5
|
+
//
|
|
6
|
+
// Classification is SHAPE FIRST, NAME SECOND. Verb spellings diverge wildly
|
|
7
|
+
// between GraphQL ecosystems (Hasura `insert_x_one`, Amplify `createTodo`,
|
|
8
|
+
// Linear `issueCreate`, PostGraphile `createUser` — and PostGraphile's
|
|
9
|
+
// inflector plugin can change them wholesale), but the type shapes do not:
|
|
10
|
+
// a query returning the entity type behind a single required id argument is
|
|
11
|
+
// a load in every one of them.
|
|
12
|
+
//
|
|
13
|
+
// Anything on Mutation that touches an entity but matches no CRUD shape
|
|
14
|
+
// becomes an ACTION on a canonical op, reaching the SDK as an
|
|
15
|
+
// `$action`-discriminated point — the same mechanism REST action paths
|
|
16
|
+
// (/planet/{id}/terraform) already use. Nothing is ever dropped silently.
|
|
17
|
+
//
|
|
18
|
+
// `classifyGraphQLField` is a pure function of plain JSON, so it is driven
|
|
19
|
+
// by a shared TSV fixture and can be ported to Go unchanged.
|
|
20
|
+
|
|
21
|
+
import { each } from 'jostraca'
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
ApiDefContext,
|
|
25
|
+
Guide,
|
|
26
|
+
GuideEntity,
|
|
27
|
+
GuidePath,
|
|
28
|
+
} from '../types'
|
|
29
|
+
|
|
30
|
+
import type { GqlDef, GqlField, GqlType } from '../parse/graphql'
|
|
31
|
+
|
|
32
|
+
import { canonize, depluralize, normalizeFieldName } from '../utility'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
// Op the classifier can assign. 'update' hosts id-bearing actions,
|
|
36
|
+
// 'create' hosts id-less ones.
|
|
37
|
+
type GqlOpName = 'load' | 'list' | 'create' | 'update' | 'remove'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
// Shape of a root field's return type, derived before classification so the
|
|
41
|
+
// classifier itself stays free of schema-object access.
|
|
42
|
+
type GqlRetShape = {
|
|
43
|
+
kind: 'entity' | 'connection' | 'list' | 'payload' | 'scalar' | 'other'
|
|
44
|
+
entity?: string
|
|
45
|
+
// Connection only: the field holding the node array ('nodes' | 'edges').
|
|
46
|
+
nodes?: string
|
|
47
|
+
// Payload only: the field the entity is wrapped in, if any.
|
|
48
|
+
unwrap?: string
|
|
49
|
+
// Payload only: a delete-ish payload carries no entity.
|
|
50
|
+
deleteish?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
type GqlArgSig = {
|
|
55
|
+
name: string
|
|
56
|
+
gqltype: string
|
|
57
|
+
reqd: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
// Everything the classifier needs about one root field.
|
|
62
|
+
type GqlFieldSig = {
|
|
63
|
+
optype: 'query' | 'mutation'
|
|
64
|
+
name: string
|
|
65
|
+
args: GqlArgSig[]
|
|
66
|
+
ret: GqlRetShape
|
|
67
|
+
inputTypeName?: string
|
|
68
|
+
// Entity named by the field itself, used when the return type carries
|
|
69
|
+
// none (see nameEntityType).
|
|
70
|
+
nameEntity?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
type GqlClassification = {
|
|
75
|
+
exclude?: boolean
|
|
76
|
+
entity?: string
|
|
77
|
+
op?: GqlOpName
|
|
78
|
+
action?: string
|
|
79
|
+
optype?: 'query' | 'mutation'
|
|
80
|
+
why: string[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
// Naming profile. Profiles only supply name patterns layered ON TOP of the
|
|
85
|
+
// shape rules; 'none' is the conservative default that relies on shape alone
|
|
86
|
+
// and leans on the guide file for anything ambiguous.
|
|
87
|
+
type GqlProfile = 'linear' | 'relay' | 'none'
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
const CREATE_RE = /^(create|insert|add|new)$/i
|
|
91
|
+
const UPDATE_RE = /^(update|edit|modify|patch|set)$/i
|
|
92
|
+
const REMOVE_RE = /^(delete|remove|destroy|drop)$/i
|
|
93
|
+
|
|
94
|
+
// Type-name suffixes that mark schema machinery rather than API entities.
|
|
95
|
+
const MACHINERY_RE =
|
|
96
|
+
/(Connection|Edge|PageInfo|Payload|Input|Filter|Comparator|Sort|OrderBy)$/
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
// Strip a leading entity name off a mutation field name, returning the
|
|
100
|
+
// residual verb. `issueCreate` -> `Create`; `createIssue` -> `create`.
|
|
101
|
+
function splitEntityVerb(fieldName: string, entity: string): string {
|
|
102
|
+
const lowerField = fieldName.toLowerCase()
|
|
103
|
+
const lowerEnt = entity.toLowerCase()
|
|
104
|
+
|
|
105
|
+
if (lowerField.startsWith(lowerEnt)) {
|
|
106
|
+
return fieldName.slice(entity.length)
|
|
107
|
+
}
|
|
108
|
+
if (lowerField.endsWith(lowerEnt)) {
|
|
109
|
+
return fieldName.slice(0, fieldName.length - entity.length)
|
|
110
|
+
}
|
|
111
|
+
return fieldName
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
// Does this field take a required id-ish argument?
|
|
116
|
+
//
|
|
117
|
+
// Any required id counts, not just a lone one: a command like
|
|
118
|
+
// `planetForbid(id: String!, forbid: Boolean!)` addresses an existing record
|
|
119
|
+
// just as much as `planetArchive(id: String!)` does. Requiring it to be the
|
|
120
|
+
// only required argument made an operation stop looking id-addressed the
|
|
121
|
+
// moment the API made a second argument mandatory, which flipped it from an
|
|
122
|
+
// update action to a create.
|
|
123
|
+
function idArg(args: GqlArgSig[]): GqlArgSig | undefined {
|
|
124
|
+
return args.find(
|
|
125
|
+
(a: GqlArgSig) => a.reqd && /^(id|.*Id)$/i.test(a.name))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
// Classify one root field. PURE: plain-JSON in, plain-JSON out, no schema
|
|
130
|
+
// objects, no closures, no I/O — so a shared TSV fixture can drive this in
|
|
131
|
+
// both TypeScript and Go.
|
|
132
|
+
function classifyGraphQLField(
|
|
133
|
+
sig: GqlFieldSig,
|
|
134
|
+
profile: GqlProfile
|
|
135
|
+
): GqlClassification {
|
|
136
|
+
const why: string[] = []
|
|
137
|
+
const ret = sig.ret
|
|
138
|
+
|
|
139
|
+
if ('scalar' === ret.kind || 'other' === ret.kind) {
|
|
140
|
+
why.push('ret:' + ret.kind)
|
|
141
|
+
return { exclude: true, why }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A payload that carries no entity (Linear's DeletePayload is just
|
|
145
|
+
// `entityId`) still belongs to the entity its field NAMES. Without this,
|
|
146
|
+
// every such delete mutation is dropped and the entity silently loses its
|
|
147
|
+
// remove op. Confined to payload returns: a query returning a scalar must
|
|
148
|
+
// not be rescued by its name.
|
|
149
|
+
const entity = ret.entity ??
|
|
150
|
+
('payload' === ret.kind ? sig.nameEntity : undefined)
|
|
151
|
+
|
|
152
|
+
if (null == entity) {
|
|
153
|
+
why.push('ret:' + ret.kind + ':no-entity')
|
|
154
|
+
return { exclude: true, why }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (null == ret.entity) {
|
|
158
|
+
why.push('entity-by-name:' + entity)
|
|
159
|
+
}
|
|
160
|
+
const id = idArg(sig.args)
|
|
161
|
+
|
|
162
|
+
if ('query' === sig.optype) {
|
|
163
|
+
// Connection or list of the entity -> list.
|
|
164
|
+
if ('connection' === ret.kind || 'list' === ret.kind) {
|
|
165
|
+
why.push('query:' + ret.kind)
|
|
166
|
+
return { entity, op: 'list', optype: 'query', why }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Single entity behind one required id -> load.
|
|
170
|
+
if ('entity' === ret.kind) {
|
|
171
|
+
if (null != id) {
|
|
172
|
+
why.push('query:entity:id=' + id.name)
|
|
173
|
+
return { entity, op: 'load', optype: 'query', why }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// No id argument: a singleton accessor (viewer, organization). Still a
|
|
177
|
+
// load — the op simply takes no id.
|
|
178
|
+
if (0 === sig.args.filter((a: GqlArgSig) => a.reqd).length) {
|
|
179
|
+
why.push('query:entity:singleton')
|
|
180
|
+
return { entity, op: 'load', optype: 'query', why }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
why.push('query:entity:args')
|
|
184
|
+
return { entity, op: 'load', optype: 'query', why }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
why.push('query:unmatched:' + ret.kind)
|
|
188
|
+
return { exclude: true, why }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- mutation ---
|
|
192
|
+
const verb = splitEntityVerb(sig.name, entity).replace(/^[_-]+/, '')
|
|
193
|
+
const input = sig.inputTypeName ?? ''
|
|
194
|
+
|
|
195
|
+
// create: <Entity>CreateInput, or a create-ish verb, and no id argument.
|
|
196
|
+
const createByInput = new RegExp('^' + entity + '(Create|Insert|New)Input$', 'i').test(input)
|
|
197
|
+
if (createByInput || (CREATE_RE.test(verb) && null == id)) {
|
|
198
|
+
why.push(createByInput ? 'mutation:input:' + input : 'mutation:verb:' + verb)
|
|
199
|
+
return { entity, op: 'create', optype: 'mutation', why }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// update: <Entity>UpdateInput, or an update-ish verb.
|
|
203
|
+
const updateByInput = new RegExp('^' + entity + '(Update|Edit|Patch|Set)Input$', 'i').test(input)
|
|
204
|
+
if (updateByInput || UPDATE_RE.test(verb)) {
|
|
205
|
+
why.push(updateByInput ? 'mutation:input:' + input : 'mutation:verb:' + verb)
|
|
206
|
+
return { entity, op: 'update', optype: 'mutation', why }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// remove: a delete-ish verb.
|
|
210
|
+
if (REMOVE_RE.test(verb)) {
|
|
211
|
+
why.push('mutation:verb:' + verb)
|
|
212
|
+
return { entity, op: 'remove', optype: 'mutation', why }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Everything else on Mutation is a command: fold it onto a canonical op as
|
|
216
|
+
// an action, exactly as REST action paths are folded. Id-bearing commands
|
|
217
|
+
// ride update (they address an existing record); id-less ones ride create.
|
|
218
|
+
const action = canonize(normalizeFieldName(verb || sig.name))
|
|
219
|
+
const host: GqlOpName = null != id ? 'update' : 'create'
|
|
220
|
+
why.push('mutation:action:' + action + ':host=' + host +
|
|
221
|
+
(profile === 'none' ? '' : ':profile=' + profile))
|
|
222
|
+
|
|
223
|
+
return { entity, op: host, action, optype: 'mutation', why }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
// Derive the return shape of a root field from the normalised type map.
|
|
228
|
+
// Separated from classification so the classifier stays plain-JSON pure.
|
|
229
|
+
function deriveRetShape(
|
|
230
|
+
field: GqlField,
|
|
231
|
+
types: Record<string, GqlType>
|
|
232
|
+
): GqlRetShape {
|
|
233
|
+
const named = types[field.type]
|
|
234
|
+
|
|
235
|
+
if (null == named) {
|
|
236
|
+
return { kind: 'scalar' }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if ('SCALAR' === named.kind || 'ENUM' === named.kind) {
|
|
240
|
+
return { kind: 'scalar' }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if ('OBJECT' !== named.kind && 'INTERFACE' !== named.kind) {
|
|
244
|
+
return { kind: 'other' }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Relay connection: has pageInfo plus nodes and/or edges.
|
|
248
|
+
const fnames = Object.keys(named.fields)
|
|
249
|
+
if (fnames.includes('pageInfo') &&
|
|
250
|
+
(fnames.includes('nodes') || fnames.includes('edges'))) {
|
|
251
|
+
|
|
252
|
+
if (fnames.includes('nodes')) {
|
|
253
|
+
return {
|
|
254
|
+
kind: 'connection',
|
|
255
|
+
entity: named.fields.nodes.type,
|
|
256
|
+
nodes: 'nodes',
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Edges-only connection: the entity is the EDGE'S NODE type, not the
|
|
261
|
+
// edge wrapper. Taking IssueEdge here would spread a fragment declared
|
|
262
|
+
// on IssueEdge inside `edges { node { ... } }` (a validation error) and
|
|
263
|
+
// unwrap the response to edge wrappers instead of entities.
|
|
264
|
+
const edgeType = types[named.fields.edges.type]
|
|
265
|
+
const nodeType = edgeType?.fields?.node?.type
|
|
266
|
+
|
|
267
|
+
if (null != nodeType) {
|
|
268
|
+
return { kind: 'connection', entity: nodeType, nodes: 'edges' }
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Mutation payload wrapper: <X>Payload holding the entity (plus success /
|
|
273
|
+
// lastSyncId style metadata).
|
|
274
|
+
if (/Payload$/.test(named.name)) {
|
|
275
|
+
// Candidates: single object-typed fields that are not machinery. LISTS
|
|
276
|
+
// are excluded — the widespread `errors: [UserError!]!` convention would
|
|
277
|
+
// otherwise win on sort order and make the payload's error collection the
|
|
278
|
+
// "entity", unwrapping errors instead of the record.
|
|
279
|
+
const candidates = fnames.filter((fname: string) => {
|
|
280
|
+
const f = named.fields[fname]
|
|
281
|
+
const ftype = types[f.type]
|
|
282
|
+
return null != ftype && 'OBJECT' === ftype.kind &&
|
|
283
|
+
!f.list && !MACHINERY_RE.test(ftype.name) &&
|
|
284
|
+
!/^(errors?|userErrors?)$/i.test(fname)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// Prefer the field whose name matches the payload's own entity prefix
|
|
288
|
+
// (IssuePayload -> issue), which is the convention every CRUD-regular
|
|
289
|
+
// GraphQL API follows; fall back to the single remaining candidate.
|
|
290
|
+
const prefix = named.name.replace(/Payload$/, '')
|
|
291
|
+
const byName = candidates.find((fname: string) =>
|
|
292
|
+
fname.toLowerCase() === prefix.toLowerCase() ||
|
|
293
|
+
types[named.fields[fname].type]?.name === prefix)
|
|
294
|
+
|
|
295
|
+
const chosen = byName ?? candidates[0]
|
|
296
|
+
|
|
297
|
+
if (null != chosen) {
|
|
298
|
+
return {
|
|
299
|
+
kind: 'payload',
|
|
300
|
+
entity: types[named.fields[chosen].type].name,
|
|
301
|
+
unwrap: chosen,
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return { kind: 'payload', deleteish: true }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (MACHINERY_RE.test(named.name)) {
|
|
309
|
+
return { kind: 'other' }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// A list of the entity is a list op even without connection machinery.
|
|
313
|
+
if (field.list) {
|
|
314
|
+
return { kind: 'list', entity: named.name }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { kind: 'entity', entity: named.name }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
// Build the classifier signature for a root field.
|
|
322
|
+
function fieldSig(
|
|
323
|
+
optype: 'query' | 'mutation',
|
|
324
|
+
field: GqlField,
|
|
325
|
+
types: Record<string, GqlType>
|
|
326
|
+
): GqlFieldSig {
|
|
327
|
+
// The input-object argument, if any, drives create/update detection.
|
|
328
|
+
let inputTypeName: string | undefined = undefined
|
|
329
|
+
for (const arg of field.args) {
|
|
330
|
+
const at = types[arg.type]
|
|
331
|
+
if (null != at && 'INPUT_OBJECT' === at.kind) {
|
|
332
|
+
inputTypeName = at.name
|
|
333
|
+
break
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
optype,
|
|
339
|
+
name: field.name,
|
|
340
|
+
args: field.args.map((a) => ({
|
|
341
|
+
name: a.name, gqltype: a.gqltype, reqd: a.reqd,
|
|
342
|
+
})),
|
|
343
|
+
ret: deriveRetShape(field, types),
|
|
344
|
+
inputTypeName,
|
|
345
|
+
nameEntity: nameEntityType(field.name, types),
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
// Verb suffixes a mutation field name may carry, longest first so
|
|
351
|
+
// `issueUnarchive` strips `Unarchive` rather than `Archive`.
|
|
352
|
+
const NAME_VERBS = [
|
|
353
|
+
'Unarchive', 'Archive', 'Delete', 'Remove', 'Destroy',
|
|
354
|
+
'Create', 'Update', 'Insert', 'Upsert',
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
// Resolve the entity a mutation NAMES, for payloads that do not carry one.
|
|
359
|
+
// Linear's DeletePayload holds `entityId: String!` and nothing else, so
|
|
360
|
+
// `commentDelete` has no entity in its return type — the field name is the
|
|
361
|
+
// only signal, and by convention it is a reliable one.
|
|
362
|
+
function nameEntityType(
|
|
363
|
+
fieldName: string,
|
|
364
|
+
types: Record<string, GqlType>
|
|
365
|
+
): string | undefined {
|
|
366
|
+
for (const verb of NAME_VERBS) {
|
|
367
|
+
if (!fieldName.endsWith(verb)) {
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const stem = fieldName.slice(0, fieldName.length - verb.length)
|
|
372
|
+
if ('' === stem) {
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const candidate = stem.charAt(0).toUpperCase() + stem.slice(1)
|
|
377
|
+
const gtype = types[candidate]
|
|
378
|
+
|
|
379
|
+
if (null != gtype && 'OBJECT' === gtype.kind) {
|
|
380
|
+
return candidate
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return undefined
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
// Entity model name from a GraphQL type name: Issue -> issue,
|
|
389
|
+
// WorkflowState -> workflow_state (canonize handles the casing rules that
|
|
390
|
+
// the REST path classifier already uses).
|
|
391
|
+
function entityName(typeName: string): string {
|
|
392
|
+
return depluralize(canonize(normalizeFieldName(typeName)))
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
function newGuidePath(): GuidePath {
|
|
397
|
+
return {
|
|
398
|
+
why_path: [],
|
|
399
|
+
action: {},
|
|
400
|
+
rename: { param: {} },
|
|
401
|
+
op: {},
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
// The GraphQL guide strategy.
|
|
407
|
+
async function graphql01(ctx: ApiDefContext): Promise<Guide> {
|
|
408
|
+
const def: GqlDef = ctx.def
|
|
409
|
+
const profile: GqlProfile = (ctx.opts.profile ?? 'none') as GqlProfile
|
|
410
|
+
|
|
411
|
+
const guide: Guide = {
|
|
412
|
+
control: {},
|
|
413
|
+
entity: {},
|
|
414
|
+
metrics: {
|
|
415
|
+
count: {
|
|
416
|
+
path: 0,
|
|
417
|
+
field: 0,
|
|
418
|
+
method: 0,
|
|
419
|
+
tag: 0,
|
|
420
|
+
cmp: 0,
|
|
421
|
+
entity: 0,
|
|
422
|
+
origcmprefs: {},
|
|
423
|
+
},
|
|
424
|
+
found: {
|
|
425
|
+
tag: {},
|
|
426
|
+
cmp: {},
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const types = def.types ?? {}
|
|
432
|
+
|
|
433
|
+
const roots: { optype: 'query' | 'mutation', fields: Record<string, GqlField> }[] = [
|
|
434
|
+
{ optype: 'query', fields: def.query ?? {} },
|
|
435
|
+
{ optype: 'mutation', fields: def.mutation ?? {} },
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
for (const root of roots) {
|
|
439
|
+
// Sorted iteration: byte-stable guide output.
|
|
440
|
+
for (const fname of Object.keys(root.fields).sort()) {
|
|
441
|
+
guide.metrics.count.field++
|
|
442
|
+
|
|
443
|
+
const field = root.fields[fname]
|
|
444
|
+
const sig = fieldSig(root.optype, field, types)
|
|
445
|
+
const cls = classifyGraphQLField(sig, profile)
|
|
446
|
+
|
|
447
|
+
if (cls.exclude || null == cls.entity || null == cls.op) {
|
|
448
|
+
ctx.log.debug({
|
|
449
|
+
point: 'graphql-exclude',
|
|
450
|
+
field: fname,
|
|
451
|
+
note: cls.why.join(';'),
|
|
452
|
+
})
|
|
453
|
+
continue
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const entname = entityName(cls.entity)
|
|
457
|
+
|
|
458
|
+
const gent: GuideEntity = guide.entity[entname] ?? {
|
|
459
|
+
name: entname,
|
|
460
|
+
orig: cls.entity,
|
|
461
|
+
field: {},
|
|
462
|
+
path: {},
|
|
463
|
+
}
|
|
464
|
+
guide.entity[entname] = gent
|
|
465
|
+
|
|
466
|
+
const gfields = (gent.field = gent.field ?? {})
|
|
467
|
+
const gpath: GuidePath = gfields[fname] ?? newGuidePath()
|
|
468
|
+
gfields[fname] = gpath
|
|
469
|
+
|
|
470
|
+
gpath.why_path.push(cls.why.join(';'))
|
|
471
|
+
|
|
472
|
+
if (null != cls.action) {
|
|
473
|
+
gpath.action[cls.action] = {
|
|
474
|
+
kind: 'graphql',
|
|
475
|
+
why_action: ['mutation:' + fname],
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
gpath.op[cls.op] = {
|
|
480
|
+
// GraphQL points synthesize POST; optype carries the real distinction.
|
|
481
|
+
method: 'POST',
|
|
482
|
+
optype: cls.optype,
|
|
483
|
+
why_op: cls.why.join(';') as any,
|
|
484
|
+
transform: { req: undefined, res: undefined },
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
guide.metrics.count.entity = Object.keys(guide.entity).length
|
|
490
|
+
|
|
491
|
+
ctx.log.info({
|
|
492
|
+
point: 'graphql-guide',
|
|
493
|
+
note: `entities=${guide.metrics.count.entity} ` +
|
|
494
|
+
`fields=${guide.metrics.count.field}`,
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
return guide
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
export {
|
|
502
|
+
graphql01,
|
|
503
|
+
classifyGraphQLField,
|
|
504
|
+
deriveRetShape,
|
|
505
|
+
fieldSig,
|
|
506
|
+
entityName,
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export type {
|
|
510
|
+
GqlFieldSig,
|
|
511
|
+
GqlArgSig,
|
|
512
|
+
GqlRetShape,
|
|
513
|
+
GqlClassification,
|
|
514
|
+
GqlProfile,
|
|
515
|
+
GqlOpName,
|
|
516
|
+
}
|
package/src/guide/guide.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { items, isempty } from '@voxgig/struct'
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
import { heuristic01 } from './heuristic01'
|
|
13
|
+
import { graphql01 } from './graphql01'
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
import {
|
|
@@ -165,6 +166,9 @@ async function buildBaseGuide(ctx: ApiDefContext) {
|
|
|
165
166
|
if ('heuristic01' === ctx.opts.strategy) {
|
|
166
167
|
baseguide = await heuristic01(ctx)
|
|
167
168
|
}
|
|
169
|
+
else if ('graphql01' === ctx.opts.strategy) {
|
|
170
|
+
baseguide = await graphql01(ctx)
|
|
171
|
+
}
|
|
168
172
|
else {
|
|
169
173
|
throw new Error('Unknown guide strategy: ' + ctx.opts.strategy)
|
|
170
174
|
}
|
|
@@ -201,22 +205,32 @@ async function buildBaseGuide(ctx: ApiDefContext) {
|
|
|
201
205
|
metrics: count: path: ${metrics.count.path}
|
|
202
206
|
metrics: count: method: ${metrics.count.method}`)
|
|
203
207
|
|
|
208
|
+
// Root-field count is GraphQL-only; omit it for REST guides so their
|
|
209
|
+
// emitted base-guide files stay byte-identical.
|
|
210
|
+
if (0 < (metrics.count.field ?? 0)) {
|
|
211
|
+
guideBlocks.push(` metrics: count: field: ${metrics.count.field}`)
|
|
212
|
+
}
|
|
213
|
+
|
|
204
214
|
// NOTE: items(...) sorts the iteration elements, so the generated model code
|
|
205
215
|
// is deterministic.
|
|
206
216
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
+
// Emit one guide entry. REST guides key entries by path, GraphQL guides by
|
|
218
|
+
// schema root field (`branch`); the body is otherwise identical, so both
|
|
219
|
+
// share this emitter. GraphQL ops carry `optype` ALONGSIDE `method: POST`,
|
|
220
|
+
// which keeps every downstream transform that reads gop.method working
|
|
221
|
+
// unchanged while recording the query/mutation distinction.
|
|
222
|
+
const emitEntry = (
|
|
223
|
+
branch: 'path' | 'field',
|
|
224
|
+
entname: string,
|
|
225
|
+
entity: GuideEntity,
|
|
226
|
+
entrykey: string,
|
|
227
|
+
path: GuidePath
|
|
228
|
+
) => {
|
|
229
|
+
{
|
|
230
|
+
debugpath(entrykey, null, 'BASE-GUIDE', entname, entrykey,
|
|
217
231
|
formatJSONIC(path, { hsepd: 0, $: true, color: true }))
|
|
218
232
|
|
|
219
|
-
guideBlocks.push(`
|
|
233
|
+
guideBlocks.push(` ${branch}: ${qs(entrykey)}: {` +
|
|
220
234
|
sw(0 < path.why_path.length ?
|
|
221
235
|
' # ent=' + entname + ';' +
|
|
222
236
|
(entity.orig !== entname && null != entity.orig ? 'orig=' + entity.orig + ';' : '') +
|
|
@@ -241,6 +255,9 @@ async function buildBaseGuide(ctx: ApiDefContext) {
|
|
|
241
255
|
items(path.op).map(([opname, op]: [string, GuidePathOp]) => {
|
|
242
256
|
guideBlocks.push(` op: ${opname}: method: *${op.method}` +
|
|
243
257
|
sw(0 < op.why_op.length ? ' # ' + op.why_op : ''))
|
|
258
|
+
if (null != op.optype) {
|
|
259
|
+
guideBlocks.push(` op: ${opname}: optype: *${op.optype}`)
|
|
260
|
+
}
|
|
244
261
|
// Each transform is emitted only when set, and each on its own terms.
|
|
245
262
|
// (An earlier req-GUARDED block pushed a second res line built from
|
|
246
263
|
// op.transform.res — emitting `transform: res: *undefined` whenever a
|
|
@@ -268,7 +285,20 @@ async function buildBaseGuide(ctx: ApiDefContext) {
|
|
|
268
285
|
})
|
|
269
286
|
|
|
270
287
|
guideBlocks.push(` }`)
|
|
271
|
-
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
items(baseguide.entity).map(([entname, entity]: [string, GuideEntity]) => {
|
|
292
|
+
|
|
293
|
+
guideBlocks.push(`
|
|
294
|
+
entity: ${entname}: {`)
|
|
295
|
+
|
|
296
|
+
// NOTE: items(...) sorts the entries, so output is deterministic.
|
|
297
|
+
items(entity.path).map(([pathstr, path]: [string, GuidePath]) =>
|
|
298
|
+
emitEntry('path', entname, entity, pathstr, path))
|
|
299
|
+
|
|
300
|
+
items((entity as any).field).map(([fieldstr, path]: [string, GuidePath]) =>
|
|
301
|
+
emitEntry('field', entname, entity, fieldstr, path))
|
|
272
302
|
|
|
273
303
|
guideBlocks.push(` }`)
|
|
274
304
|
})
|
|
@@ -304,7 +334,55 @@ async function buildBaseGuide(ctx: ApiDefContext) {
|
|
|
304
334
|
|
|
305
335
|
|
|
306
336
|
|
|
337
|
+
// GraphQL coverage guard: every Query/Mutation root field must either be
|
|
338
|
+
// assigned to an entity op or be deliberately excluded by the classifier
|
|
339
|
+
// (machinery types, scalar returns). Mirrors the REST PATH MISMATCH check —
|
|
340
|
+
// silence about an unclassified field is how an API silently loses surface.
|
|
341
|
+
function validateGraphqlBaseGuide(ctx: ApiDefContext, baseguide: any) {
|
|
342
|
+
const covered: Record<string, boolean> = {}
|
|
343
|
+
|
|
344
|
+
each(baseguide.entity, (entm: GuideEntity) => {
|
|
345
|
+
each((entm as any).field, (fieldm: GuidePath, fieldStr: string) => {
|
|
346
|
+
if (!isempty(fieldm.op)) {
|
|
347
|
+
covered[fieldStr] = true
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
const uncovered: string[] = []
|
|
353
|
+
for (const roots of [ctx.def?.query, ctx.def?.mutation]) {
|
|
354
|
+
for (const fname of Object.keys(roots ?? {}).sort()) {
|
|
355
|
+
if (!covered[fname]) {
|
|
356
|
+
uncovered.push(fname)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Unclassified root fields are expected (scalars like `version`, machinery
|
|
362
|
+
// returns), so this is a warning rather than a hard failure — but it is
|
|
363
|
+
// always reported, so a missed entity is visible.
|
|
364
|
+
if (0 < uncovered.length) {
|
|
365
|
+
ctx.warn({
|
|
366
|
+
note: `GraphQL root fields not mapped to an entity op: ` +
|
|
367
|
+
uncovered.join(', '),
|
|
368
|
+
uncovered,
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
ctx.log.info({
|
|
373
|
+
point: 'graphql-coverage',
|
|
374
|
+
note: `mapped=${Object.keys(covered).length} unmapped=${uncovered.length}`,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
|
|
307
379
|
function validateBaseBuide(ctx: ApiDefContext, baseguide: any) {
|
|
380
|
+
// GraphQL guides key entries by root field, not path: the path-based
|
|
381
|
+
// reconciliation below has nothing to compare.
|
|
382
|
+
if (true === ctx.def?.graphql) {
|
|
383
|
+
return validateGraphqlBaseGuide(ctx, baseguide)
|
|
384
|
+
}
|
|
385
|
+
|
|
308
386
|
const srcm: any = {}
|
|
309
387
|
|
|
310
388
|
// Each orig path.
|