@voxgig/apidef 6.5.1 → 7.0.1
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 +11 -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,311 @@
|
|
|
1
|
+
/* Copyright (c) 2024-2026 Voxgig, MIT License */
|
|
2
|
+
|
|
3
|
+
// Render the GraphQL wire data onto each point: the complete operation
|
|
4
|
+
// document, its variable bindings, the response unwrap path, and (for list
|
|
5
|
+
// ops) the pagination descriptor.
|
|
6
|
+
//
|
|
7
|
+
// Documents are computed HERE, once, and stored in the model as strings.
|
|
8
|
+
// The alternative — shipping structured selection data and assembling query
|
|
9
|
+
// text inside every generated SDK — would mean one query assembler per
|
|
10
|
+
// language target, all of which must stay semantically identical. One
|
|
11
|
+
// renderer in apidef is the whole reason GraphQL support stays affordable
|
|
12
|
+
// across the target matrix.
|
|
13
|
+
//
|
|
14
|
+
// Documents are rendered SINGLE-LINE with sorted selection fields, so the
|
|
15
|
+
// emitted model is byte-stable and schema drift shows up in model diffs.
|
|
16
|
+
|
|
17
|
+
import { each } from 'jostraca'
|
|
18
|
+
|
|
19
|
+
import type { TransformResult, Transform } from '../transform'
|
|
20
|
+
|
|
21
|
+
import { KIT } from '../types'
|
|
22
|
+
|
|
23
|
+
import type { KitModel, GuidePath } from '../types'
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
OpName,
|
|
27
|
+
ModelOp,
|
|
28
|
+
ModelEntity,
|
|
29
|
+
ModelPoint,
|
|
30
|
+
ModelGraphqlVar,
|
|
31
|
+
} from '../model'
|
|
32
|
+
|
|
33
|
+
import { deriveRetShape } from '../guide/graphql01'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
// Fields the default fragment never selects on a to-one relation: the stub
|
|
37
|
+
// carries the id only, so the caller loads the related entity through its
|
|
38
|
+
// own entity op.
|
|
39
|
+
const REL_STUB = '{ id }'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
function pascal(s: string): string {
|
|
43
|
+
return s.replace(/(^|[_-])([a-z])/g, (_m, _p, c) => c.toUpperCase())
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
// Build the selection set for an entity type: every non-deprecated scalar,
|
|
48
|
+
// skipping fields that require arguments (they cannot appear in a fixed
|
|
49
|
+
// fragment without binding those arguments), plus an id stub per to-one
|
|
50
|
+
// relation. Sorted — byte-stability.
|
|
51
|
+
function selectionFields(typeName: string, def: any): string[] {
|
|
52
|
+
const gtype = def.types?.[typeName]
|
|
53
|
+
if (null == gtype) {
|
|
54
|
+
return []
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const out: string[] = []
|
|
58
|
+
|
|
59
|
+
for (const fname of Object.keys(gtype.fields)) {
|
|
60
|
+
const f = gtype.fields[fname]
|
|
61
|
+
|
|
62
|
+
if (f.deprecated) {
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
if (f.args.some((a: any) => a.reqd)) {
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ftype = def.types?.[f.type]
|
|
70
|
+
const kind = ftype?.kind
|
|
71
|
+
|
|
72
|
+
if ('SCALAR' === kind || 'ENUM' === kind) {
|
|
73
|
+
out.push(fname)
|
|
74
|
+
}
|
|
75
|
+
else if (('OBJECT' === kind || 'INTERFACE' === kind) && !f.list) {
|
|
76
|
+
if (null != ftype.fields?.id) {
|
|
77
|
+
out.push(fname + ' ' + REL_STUB)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return out.sort()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
// Scalar fields of a payload type, for payloads that carry no entity. Used
|
|
87
|
+
// when there is nothing to spread a fragment on, so the operation still has
|
|
88
|
+
// a valid selection set.
|
|
89
|
+
function payloadScalarFields(typeName: string, def: any): string[] {
|
|
90
|
+
const gtype = def.types?.[typeName]
|
|
91
|
+
if (null == gtype) {
|
|
92
|
+
return []
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const out: string[] = []
|
|
96
|
+
|
|
97
|
+
for (const fname of Object.keys(gtype.fields)) {
|
|
98
|
+
const f = gtype.fields[fname]
|
|
99
|
+
const kind = def.types?.[f.type]?.kind
|
|
100
|
+
|
|
101
|
+
if (!f.deprecated && !f.args.some((a: any) => a.reqd) &&
|
|
102
|
+
('SCALAR' === kind || 'ENUM' === kind)) {
|
|
103
|
+
out.push(fname)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return out.sort()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
// Variable bindings for a root field: one per argument. `from` is the op
|
|
112
|
+
// argument the value is read from; for the input-object argument that is the
|
|
113
|
+
// request data itself.
|
|
114
|
+
function buildVars(fielddef: any, def: any): ModelGraphqlVar[] {
|
|
115
|
+
const args = fielddef?.args ?? []
|
|
116
|
+
|
|
117
|
+
// The whole-request-body binding is the SINGLE-entity-input convention
|
|
118
|
+
// (issueCreate(input: IssueCreateInput!)). A field taking several input
|
|
119
|
+
// objects — items(filter: Filter, orderBy: OrderBy) — must bind each from
|
|
120
|
+
// its own argument, or they all receive the same value and at least one
|
|
121
|
+
// fails input validation.
|
|
122
|
+
const inputs = args.filter((a: any) => {
|
|
123
|
+
const atype = def.types?.[a.type]
|
|
124
|
+
return null != atype && 'INPUT_OBJECT' === atype.kind
|
|
125
|
+
})
|
|
126
|
+
const soleInput = 1 === inputs.length ? inputs[0].name : undefined
|
|
127
|
+
|
|
128
|
+
return args.map((arg: any) => {
|
|
129
|
+
const v: any = {
|
|
130
|
+
name: arg.name,
|
|
131
|
+
from: arg.name === soleInput ? '' : arg.name,
|
|
132
|
+
gqltype: arg.gqltype,
|
|
133
|
+
}
|
|
134
|
+
if (undefined !== arg.deflt) {
|
|
135
|
+
v.deflt = arg.deflt
|
|
136
|
+
}
|
|
137
|
+
return v
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
// `issue(id: $id, first: $first)` — argument list wired to variables.
|
|
143
|
+
function argList(vars: ModelGraphqlVar[]): string {
|
|
144
|
+
return 0 === vars.length ? '' :
|
|
145
|
+
'(' + vars.map((v) => v.name + ': $' + v.name).join(', ') + ')'
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
// `($id: String!, $first: Int = 100)` — the operation's variable
|
|
150
|
+
// declarations. A schema default is carried through: without it a non-null
|
|
151
|
+
// argument that the schema makes omittable (`first: Int! = 100`) would fail
|
|
152
|
+
// variable coercion when the caller leaves it out.
|
|
153
|
+
function varDecl(vars: ModelGraphqlVar[]): string {
|
|
154
|
+
return 0 === vars.length ? '' :
|
|
155
|
+
'(' + vars.map((v) => '$' + v.name + ': ' + v.gqltype +
|
|
156
|
+
(undefined === (v as any).deflt ? '' :
|
|
157
|
+
' = ' + JSON.stringify((v as any).deflt))).join(', ') + ')'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
// Render one operation document, single-line.
|
|
162
|
+
function renderDoc(
|
|
163
|
+
opname: string,
|
|
164
|
+
optype: string,
|
|
165
|
+
field: string,
|
|
166
|
+
vars: ModelGraphqlVar[],
|
|
167
|
+
selection: string,
|
|
168
|
+
fragName: string,
|
|
169
|
+
fragType: string,
|
|
170
|
+
fragFields: string[]
|
|
171
|
+
): string {
|
|
172
|
+
const doc =
|
|
173
|
+
optype + ' ' + opname + varDecl(vars) +
|
|
174
|
+
' { ' + field + argList(vars) + ' ' + selection + ' }' +
|
|
175
|
+
(0 < fragFields.length ?
|
|
176
|
+
' fragment ' + fragName + ' on ' + fragType +
|
|
177
|
+
' { ' + fragFields.join(' ') + ' }' : '')
|
|
178
|
+
|
|
179
|
+
// Collapse any accidental double spacing so the string is canonical.
|
|
180
|
+
return doc.replace(/\s+/g, ' ').trim()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
const graphqlTransform: Transform = async function(
|
|
185
|
+
ctx: any,
|
|
186
|
+
): Promise<TransformResult> {
|
|
187
|
+
const { apimodel, def, guide } = ctx
|
|
188
|
+
|
|
189
|
+
if (true !== def?.graphql) {
|
|
190
|
+
return { ok: true, msg: 'graphql (skipped: not a graphql def)' }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const kit: KitModel = apimodel.main[KIT]
|
|
194
|
+
|
|
195
|
+
let msg = 'graphql '
|
|
196
|
+
|
|
197
|
+
each(kit.entity, (ment: ModelEntity, entname: string) => {
|
|
198
|
+
const gent = guide.entity[entname]
|
|
199
|
+
|
|
200
|
+
each(ment.op, (mop: ModelOp, opname: OpName) => {
|
|
201
|
+
each(mop.points, (mpoint: ModelPoint) => {
|
|
202
|
+
const rootfield = mpoint.orig
|
|
203
|
+
|
|
204
|
+
const gfield: GuidePath | undefined = (gent as any)?.field?.[rootfield]
|
|
205
|
+
const optype = (gfield?.op?.[opname] as any)?.optype ?? 'query'
|
|
206
|
+
|
|
207
|
+
const fielddef = 'mutation' === optype ?
|
|
208
|
+
def.mutation?.[rootfield] : def.query?.[rootfield]
|
|
209
|
+
|
|
210
|
+
if (null == fielddef) {
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const ret = deriveRetShape(fielddef, def.types ?? {})
|
|
215
|
+
const entityType = ret.entity ?? ''
|
|
216
|
+
|
|
217
|
+
const fragFields = selectionFields(entityType, def)
|
|
218
|
+
const fragName = pascal(entname) + 'Fields'
|
|
219
|
+
const fragSpread = 0 < fragFields.length ? '{ ...' + fragName + ' }' : '{ id }'
|
|
220
|
+
|
|
221
|
+
const vars = buildVars(fielddef, def)
|
|
222
|
+
|
|
223
|
+
// Selection shape and response unwrap both follow the return kind.
|
|
224
|
+
let selection = fragSpread
|
|
225
|
+
let respath = 'body.data.' + rootfield
|
|
226
|
+
|
|
227
|
+
if ('connection' === ret.kind) {
|
|
228
|
+
const nodes = ret.nodes ?? 'nodes'
|
|
229
|
+
selection = 'nodes' === nodes ?
|
|
230
|
+
'{ nodes ' + fragSpread + ' pageInfo { endCursor hasNextPage } }' :
|
|
231
|
+
'{ edges { node ' + fragSpread + ' } pageInfo { endCursor hasNextPage } }'
|
|
232
|
+
respath = 'body.data.' + rootfield + '.' + nodes
|
|
233
|
+
}
|
|
234
|
+
else if ('list' === ret.kind) {
|
|
235
|
+
selection = fragSpread
|
|
236
|
+
}
|
|
237
|
+
else if ('payload' === ret.kind && null == ret.entity) {
|
|
238
|
+
// Entity-less payload: Linear's DeletePayload is entityId +
|
|
239
|
+
// success + lastSyncId and nothing else. The classifier admits
|
|
240
|
+
// these (the entity comes from the field name), so the renderer
|
|
241
|
+
// must not fall through to the default `{ id }` spread — the
|
|
242
|
+
// payload HAS no id, and the server rejects the whole document.
|
|
243
|
+
// Select the payload's own scalars and unwrap to the payload.
|
|
244
|
+
const own = payloadScalarFields(fielddef.type, def)
|
|
245
|
+
selection = '{ ' + (0 < own.length ? own.join(' ') : '__typename') + ' }'
|
|
246
|
+
respath = 'body.data.' + rootfield
|
|
247
|
+
}
|
|
248
|
+
else if ('payload' === ret.kind && null != ret.unwrap) {
|
|
249
|
+
// Mutation payload wrapper: select the entity inside it (plus the
|
|
250
|
+
// conventional success flag when present) and unwrap on the way
|
|
251
|
+
// back, so create/update return the entity exactly as REST does.
|
|
252
|
+
const payloadType = def.types?.[fielddef.type]
|
|
253
|
+
const hasSuccess = null != payloadType?.fields?.success
|
|
254
|
+
selection = '{ ' + ret.unwrap + ' ' + fragSpread +
|
|
255
|
+
(hasSuccess ? ' success' : '') + ' }'
|
|
256
|
+
respath = 'body.data.' + rootfield + '.' + ret.unwrap
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Distinct operation name per point. The action comes from the GUIDE,
|
|
260
|
+
// not from mpoint.select: selectTransform runs after this stage, so
|
|
261
|
+
// $action is not set yet, and without the suffix every action point
|
|
262
|
+
// on an op would ship the same operation name (three PlanetUpdates),
|
|
263
|
+
// which is what server logs, tracing and APM key on.
|
|
264
|
+
const actionName = Object.keys((gfield as any)?.action ?? {})[0]
|
|
265
|
+
const docname = pascal(entname) + pascal(opname) +
|
|
266
|
+
(null != actionName ? pascal(actionName) : '')
|
|
267
|
+
|
|
268
|
+
// GraphQL points ride the HTTP machinery: POST to the single
|
|
269
|
+
// endpoint, no path parts. The document carries everything else.
|
|
270
|
+
mpoint.kind = 'graphql'
|
|
271
|
+
mpoint.method = 'POST'
|
|
272
|
+
mpoint.parts = []
|
|
273
|
+
|
|
274
|
+
mpoint.graphql = {
|
|
275
|
+
optype: optype as 'query' | 'mutation',
|
|
276
|
+
field: rootfield,
|
|
277
|
+
doc: renderDoc(docname, optype, rootfield, vars, selection,
|
|
278
|
+
fragName, entityType, fragFields),
|
|
279
|
+
vars,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Carried for the field transform, which derives entity fields from
|
|
283
|
+
// the same object type. The `$` suffix makes cleanTransform strip it
|
|
284
|
+
// from the emitted model — it is pipeline state, not wire data.
|
|
285
|
+
;(mpoint.graphql as any).entityType$ = entityType
|
|
286
|
+
|
|
287
|
+
if ('connection' === ret.kind) {
|
|
288
|
+
mpoint.graphql.page = {
|
|
289
|
+
style: 'relay',
|
|
290
|
+
nodes: ret.nodes ?? 'nodes',
|
|
291
|
+
cursor: 'pageInfo.endCursor',
|
|
292
|
+
more: 'pageInfo.hasNextPage',
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
mpoint.transform.res = '`' + respath + '`'
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
msg += ment.name + ' '
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
return { ok: true, msg }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
export {
|
|
308
|
+
graphqlTransform,
|
|
309
|
+
selectionFields,
|
|
310
|
+
renderDoc,
|
|
311
|
+
}
|
package/src/transform/select.ts
CHANGED
|
@@ -37,7 +37,9 @@ const selectTransform: Transform = async function(
|
|
|
37
37
|
each(kit.entity, (ment: ModelEntity, _entname: string) => {
|
|
38
38
|
each(ment.op, (mop: ModelOp, _opname: OpName) => {
|
|
39
39
|
each(mop.points, (mpoint: ModelPoint) => {
|
|
40
|
-
|
|
40
|
+
// GraphQL defs have no `paths`; the lookup is only passed through to
|
|
41
|
+
// an unused parameter, so skip it rather than dereference undefined.
|
|
42
|
+
const pdef: PathDef = def.paths?.[mpoint.orig]
|
|
41
43
|
resolveSelect(guide, ment, mop, mpoint, pdef)
|
|
42
44
|
})
|
|
43
45
|
if (null != mop.points && 0 < mop.points.length) {
|
|
@@ -64,8 +66,18 @@ function resolveSelect(
|
|
|
64
66
|
|
|
65
67
|
const argkinds = ['params', 'query', 'header', 'cookie']
|
|
66
68
|
|
|
69
|
+
// `exist` names values that must be PRESENT for this point to be chosen.
|
|
70
|
+
// A GraphQL root field exposes its optional arguments (relay's first /
|
|
71
|
+
// after, filters) as params, and requiring those for selection would make
|
|
72
|
+
// list() unusable without supplying every pagination argument. Only
|
|
73
|
+
// required arguments identify a point.
|
|
74
|
+
const reqdonly = 'graphql' === (mpoint as any).kind
|
|
75
|
+
|
|
67
76
|
argkinds.map((kind: string) => {
|
|
68
77
|
each(margs[kind], (marg: ModelArg) => {
|
|
78
|
+
if (reqdonly && !marg.reqd) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
69
81
|
if (!select.exist.includes(marg.name)) {
|
|
70
82
|
select.exist.push(marg.name)
|
|
71
83
|
}
|
|
@@ -75,7 +87,12 @@ function resolveSelect(
|
|
|
75
87
|
select.exist.sort()
|
|
76
88
|
|
|
77
89
|
const gent = guide.entity[ment.name]
|
|
78
|
-
|
|
90
|
+
// REST guides key entries by path, GraphQL guides by root field.
|
|
91
|
+
const gpath = gent.path?.[mpoint.orig] ?? (gent as any).field?.[mpoint.orig]
|
|
92
|
+
|
|
93
|
+
if (null == gpath) {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
79
96
|
|
|
80
97
|
if (gpath.action) {
|
|
81
98
|
const actname = Object.keys(gpath.action).sort()[0]
|
package/src/transform/top.ts
CHANGED
|
@@ -59,7 +59,31 @@ const topTransform = async function(
|
|
|
59
59
|
// spec DOES declare auth we leave `auth` unset so the SDK's own config
|
|
60
60
|
// (main.kit.config.auth) governs. Set AFTER stringifyInfoScalars so the
|
|
61
61
|
// value stays a real boolean rather than the string "false".
|
|
62
|
-
if (
|
|
62
|
+
if (true === def.graphql) {
|
|
63
|
+
// A GraphQL schema NEVER declares HTTP auth, so specDeclaresAuth would
|
|
64
|
+
// report every secured GraphQL API (Linear included) as public and
|
|
65
|
+
// suppress all generated auth code. Take the explicit build option
|
|
66
|
+
// instead: only an option that actively says "public" emits the no-auth
|
|
67
|
+
// signal; silence leaves auth unset so the SDK's own config governs.
|
|
68
|
+
const authopt = ctx.opts?.auth
|
|
69
|
+
if (null != authopt) {
|
|
70
|
+
if (false === authopt.active) {
|
|
71
|
+
kit.info.auth = false
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
kit.info.security = {
|
|
75
|
+
scheme: authopt.scheme ?? 'apikey',
|
|
76
|
+
type: authopt.type ?? 'apiKey',
|
|
77
|
+
in: authopt.in ?? 'header',
|
|
78
|
+
name: authopt.name ?? 'Authorization',
|
|
79
|
+
// '' means a raw credential with no prefix (Linear's style);
|
|
80
|
+
// prepareAuth in generated SDKs already honours that.
|
|
81
|
+
prefix: authopt.prefix ?? '',
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else if (!specDeclaresAuth(def)) {
|
|
63
87
|
kit.info.auth = false
|
|
64
88
|
}
|
|
65
89
|
else {
|
|
@@ -105,10 +129,15 @@ const topTransform = async function(
|
|
|
105
129
|
// Swagger 2 derives it from `host` + `basePath`. If neither yields a
|
|
106
130
|
// non-empty url, the generated SDK has no way to issue requests, so fail
|
|
107
131
|
// the apidef model build rather than emit broken code.
|
|
132
|
+
// (For GraphQL the parser already synthesised servers[0] from the required
|
|
133
|
+
// `endpoint` build option, so this check passes on the same terms.)
|
|
108
134
|
const firstServerUrl: any = kit.info.servers?.[0]?.url
|
|
109
135
|
if (null == firstServerUrl || '' === String(firstServerUrl).trim()) {
|
|
110
136
|
throw new Error(
|
|
111
|
-
|
|
137
|
+
true === def.graphql ?
|
|
138
|
+
'apidef: no endpoint given for GraphQL schema' +
|
|
139
|
+
' (the endpoint build option is required).' :
|
|
140
|
+
'apidef: no server URL found in API definition (servers[0].url is required).'
|
|
112
141
|
)
|
|
113
142
|
}
|
|
114
143
|
|
package/src/types.ts
CHANGED
|
@@ -33,11 +33,41 @@ type ApiDefOptions = {
|
|
|
33
33
|
meta?: Record<string, any>
|
|
34
34
|
outprefix?: string
|
|
35
35
|
strategy?: string
|
|
36
|
+
|
|
37
|
+
// Input format. Defaults to 'OpenAPI'; sniffed from the def file name
|
|
38
|
+
// when not given (see resolveKind in apidef.ts).
|
|
39
|
+
kind?: DefKind
|
|
40
|
+
|
|
41
|
+
// GraphQL-only build inputs. A GraphQL schema carries neither a
|
|
42
|
+
// deployment URL nor an HTTP auth declaration, so both must be supplied
|
|
43
|
+
// out of band (see docs/design/graphql-ingestion.md).
|
|
44
|
+
endpoint?: string
|
|
45
|
+
auth?: ApiDefAuthOption
|
|
46
|
+
|
|
36
47
|
why?: {
|
|
37
48
|
show?: boolean
|
|
38
49
|
}
|
|
39
50
|
}
|
|
40
51
|
|
|
52
|
+
|
|
53
|
+
// Input definition format.
|
|
54
|
+
type DefKind = 'OpenAPI' | 'GraphQL'
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
// Auth descriptor for schema formats that cannot declare their own. When
|
|
58
|
+
// omitted for a GraphQL build, no auth signal is emitted either way, so the
|
|
59
|
+
// SDK's own config governs; set `active: false` to state a public API
|
|
60
|
+
// explicitly (which suppresses generated auth code, as an OpenAPI spec with
|
|
61
|
+
// no security schemes does).
|
|
62
|
+
type ApiDefAuthOption = {
|
|
63
|
+
active?: boolean
|
|
64
|
+
scheme?: string
|
|
65
|
+
type?: string
|
|
66
|
+
in?: string
|
|
67
|
+
name?: string
|
|
68
|
+
prefix?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
41
71
|
const ControlShape = Shape({
|
|
42
72
|
step: {
|
|
43
73
|
parse: true,
|
|
@@ -207,6 +237,8 @@ type GuideControl = {}
|
|
|
207
237
|
type GuideMetrics = {
|
|
208
238
|
count: {
|
|
209
239
|
path: number
|
|
240
|
+
// Schema root fields classified (GraphQL guides; 0 for OpenAPI).
|
|
241
|
+
field: number
|
|
210
242
|
method: number
|
|
211
243
|
entity: number
|
|
212
244
|
tag: number
|
|
@@ -223,6 +255,9 @@ type GuideMetrics = {
|
|
|
223
255
|
type GuideEntity = {
|
|
224
256
|
name: string
|
|
225
257
|
orig: string
|
|
258
|
+
// GraphQL guides key operations by schema root field instead of path;
|
|
259
|
+
// the two branches are mutually exclusive per guide.
|
|
260
|
+
field?: Record<string, GuidePath>
|
|
226
261
|
path: Record<string, GuidePath>
|
|
227
262
|
}
|
|
228
263
|
|
|
@@ -247,6 +282,9 @@ type GuideRenameParam = {
|
|
|
247
282
|
|
|
248
283
|
type GuidePathOp = {
|
|
249
284
|
method: string
|
|
285
|
+
// GraphQL root-field ops carry the operation type instead of relying on
|
|
286
|
+
// an HTTP verb (points still synthesize method 'POST').
|
|
287
|
+
optype?: string
|
|
250
288
|
why_op: string[]
|
|
251
289
|
transform: {
|
|
252
290
|
req: any
|
|
@@ -280,6 +318,8 @@ export type {
|
|
|
280
318
|
Log,
|
|
281
319
|
FsUtil,
|
|
282
320
|
ApiDefOptions,
|
|
321
|
+
DefKind,
|
|
322
|
+
ApiDefAuthOption,
|
|
283
323
|
ApiDefResult,
|
|
284
324
|
Control,
|
|
285
325
|
Model,
|
package/src/utility.ts
CHANGED
|
@@ -1012,6 +1012,36 @@ function canonize(s: string) {
|
|
|
1012
1012
|
}
|
|
1013
1013
|
|
|
1014
1014
|
|
|
1015
|
+
// Canonicalise a FIELD name — which is a WIRE identifier, not a type name.
|
|
1016
|
+
//
|
|
1017
|
+
// `canonize` is right for entity/type names: it snakifies and depluralizes so
|
|
1018
|
+
// `Users` and `user-items` converge on `user` / `user_item`. Applied to a
|
|
1019
|
+
// field it is actively WRONG, because the name has to match the JSON the
|
|
1020
|
+
// server actually sends:
|
|
1021
|
+
//
|
|
1022
|
+
// modelType -> canonize -> model_type (server sends modelType)
|
|
1023
|
+
// items -> canonize -> item (server sends items)
|
|
1024
|
+
//
|
|
1025
|
+
// Nothing maps back: the model's `alias.field` map is emitted empty and no
|
|
1026
|
+
// generator consumes it, so the wire name is simply lost. Across the fleet's
|
|
1027
|
+
// specs that renamed 23% of all fields (146 repos) and depluralized another
|
|
1028
|
+
// 13% — every one of those SDKs reading a key the server never sends.
|
|
1029
|
+
//
|
|
1030
|
+
// So: keep the transliteration and identifier sanitisation that stop a name
|
|
1031
|
+
// being unusable in a target language, and drop the snakify/depluralize that
|
|
1032
|
+
// change what the name MEANS. Case and plurality are preserved verbatim.
|
|
1033
|
+
const CANONIZE_FIELD_CACHE = new Map<string, string>()
|
|
1034
|
+
|
|
1035
|
+
function canonizeField(s: string) {
|
|
1036
|
+
if (null == s || '' === s) return ''
|
|
1037
|
+
const cached = CANONIZE_FIELD_CACHE.get(s)
|
|
1038
|
+
if (undefined !== cached) return cached
|
|
1039
|
+
const out = transliterate(s).replace(/[^a-zA-Z_0-9]/g, '')
|
|
1040
|
+
CANONIZE_FIELD_CACHE.set(s, out)
|
|
1041
|
+
return out
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
|
|
1015
1045
|
// Namespace-qualified schema names (ASP.NET / Java style:
|
|
1016
1046
|
// "NoFrixion.MoneyMoov.Models.PaymentRequests.MerchantPayment",
|
|
1017
1047
|
// "com.example.api.Payment") describe the type by their LAST dotted
|
|
@@ -1551,16 +1581,35 @@ function isEntityWrapperProp(propSchema: any): boolean {
|
|
|
1551
1581
|
// signal, and is still checked first.
|
|
1552
1582
|
function envelopeProp(resprops: any, opname: string): string | null {
|
|
1553
1583
|
const keys = keysof(resprops)
|
|
1554
|
-
if (
|
|
1584
|
+
if (0 === keys.length) {
|
|
1555
1585
|
return null
|
|
1556
1586
|
}
|
|
1557
1587
|
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1588
|
+
// Exactly one STRUCTURED property, with any siblings being scalars.
|
|
1589
|
+
//
|
|
1590
|
+
// The original rule demanded exactly one property full stop, which missed
|
|
1591
|
+
// the single most common envelope shape in the wild:
|
|
1592
|
+
//
|
|
1593
|
+
// { "success": true, "data": [ ... ] }
|
|
1594
|
+
// { "status": "ok", "result": { ... } }
|
|
1595
|
+
//
|
|
1596
|
+
// A boolean/string status flag beside the payload is metadata, not a
|
|
1597
|
+
// sibling of equal standing, so the body is still an envelope. UniVec's
|
|
1598
|
+
// /v1/models returns exactly this and every generated SDK — TypeScript, Go,
|
|
1599
|
+
// Python alike — returned an empty list against an API plainly serving
|
|
1600
|
+
// data.
|
|
1601
|
+
//
|
|
1602
|
+
// Scalar-only siblings keep the guard meaningful: `{ok, id}` from a delete
|
|
1603
|
+
// has no structured member and is still handed over whole, and a body with
|
|
1604
|
+
// TWO structured members is a composite we must not guess at.
|
|
1605
|
+
const structured = keys.filter((k: string) => isEntityWrapperProp(resprops[k]))
|
|
1606
|
+
if (1 !== structured.length) {
|
|
1561
1607
|
return null
|
|
1562
1608
|
}
|
|
1563
1609
|
|
|
1610
|
+
const key = structured[0]
|
|
1611
|
+
const prop = resprops[key]
|
|
1612
|
+
|
|
1564
1613
|
const islist = propIsList(prop)
|
|
1565
1614
|
if (null == islist || islist !== ('list' === opname)) {
|
|
1566
1615
|
return null
|
|
@@ -1666,6 +1715,7 @@ export {
|
|
|
1666
1715
|
VALID_CANON,
|
|
1667
1716
|
CANON_ONE,
|
|
1668
1717
|
canonize,
|
|
1718
|
+
canonizeField,
|
|
1669
1719
|
canonizeCmpName,
|
|
1670
1720
|
stripSchemaNamespace,
|
|
1671
1721
|
sanitizeSlug,
|