@voxgig/apidef 8.11.0 → 8.12.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.d.ts +4 -1
- package/dist/apidef.js +12 -34
- package/dist/apidef.js.map +1 -1
- package/dist/builder/entity/entity.js +0 -33
- package/dist/builder/entity/entity.js.map +1 -1
- package/dist/builder/entity/info.js +0 -4
- package/dist/builder/entity/info.js.map +1 -1
- package/dist/builder/flow.d.ts +4 -1
- package/dist/builder/flow.js +36 -27
- package/dist/builder/flow.js.map +1 -1
- package/dist/guide/graphql01.js +0 -55
- package/dist/guide/graphql01.js.map +1 -1
- package/dist/guide/guide.js +3 -126
- package/dist/guide/guide.js.map +1 -1
- package/dist/guide/heuristic01.js +40 -181
- package/dist/guide/heuristic01.js.map +1 -1
- package/dist/parse/graphql.js +0 -25
- package/dist/parse/graphql.js.map +1 -1
- package/dist/parse.js +51 -56
- package/dist/parse.js.map +1 -1
- package/dist/resolved.d.ts +23 -0
- package/dist/resolved.js +92 -0
- package/dist/resolved.js.map +1 -0
- package/dist/resolver.js +0 -2
- package/dist/resolver.js.map +1 -1
- package/dist/transform/args.js +2 -25
- package/dist/transform/args.js.map +1 -1
- package/dist/transform/casecollide.d.ts +3 -0
- package/dist/transform/casecollide.js +67 -0
- package/dist/transform/casecollide.js.map +1 -0
- package/dist/transform/contract.js +43 -38
- package/dist/transform/contract.js.map +1 -1
- package/dist/transform/entity.js +0 -56
- package/dist/transform/entity.js.map +1 -1
- package/dist/transform/field.js +1 -368
- package/dist/transform/field.js.map +1 -1
- package/dist/transform/flowstep.js +0 -49
- package/dist/transform/flowstep.js.map +1 -1
- package/dist/transform/graphql.js +0 -36
- package/dist/transform/graphql.js.map +1 -1
- package/dist/transform/operation.js +0 -23
- package/dist/transform/operation.js.map +1 -1
- package/dist/transform/top.js +1 -128
- package/dist/transform/top.js.map +1 -1
- package/dist/transform.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.js.map +1 -1
- package/dist/utility.d.ts +2 -1
- package/dist/utility.js +17 -316
- package/dist/utility.js.map +1 -1
- package/model/apidef.aon +20 -113
- package/model/guide.aon +1 -32
- package/package.json +5 -5
- package/src/apidef.ts +22 -33
- package/src/builder/entity/entity.ts +0 -33
- package/src/builder/entity/info.ts +0 -4
- package/src/builder/flow.ts +39 -24
- package/src/desc.ts +0 -22
- package/src/guide/graphql01.ts +0 -75
- package/src/guide/guide.ts +5 -127
- package/src/guide/heuristic01.ts +47 -182
- package/src/model.ts +6 -85
- package/src/parse/graphql.ts +0 -25
- package/src/parse.ts +61 -56
- package/src/resolved.ts +136 -0
- package/src/resolver.ts +0 -2
- package/src/transform/args.ts +2 -25
- package/src/transform/casecollide.ts +80 -0
- package/src/transform/contract.ts +44 -34
- package/src/transform/entity.ts +0 -56
- package/src/transform/field.ts +2 -368
- package/src/transform/flowstep.ts +0 -49
- package/src/transform/graphql.ts +0 -36
- package/src/transform/operation.ts +0 -23
- package/src/transform/top.ts +1 -131
- package/src/transform.ts +0 -1
- package/src/types.ts +4 -13
- package/src/utility.ts +18 -314
package/src/parse/graphql.ts
CHANGED
|
@@ -1,23 +1,5 @@
|
|
|
1
1
|
/* Copyright (c) 2024-2026 Voxgig, MIT License */
|
|
2
2
|
|
|
3
|
-
// GraphQL ingestion: normalise an SDL document or an introspection result
|
|
4
|
-
// into the plain `def` structure the guide and transform stages consume.
|
|
5
|
-
//
|
|
6
|
-
// The OpenAPI parser hands downstream stages the spec object itself, with
|
|
7
|
-
// `$ref`s resolved in place. GraphQL has no equivalent literal document, so
|
|
8
|
-
// this builds an explicit graph instead:
|
|
9
|
-
//
|
|
10
|
-
// def.types — every named type, keyed by type name
|
|
11
|
-
// def.query — root Query fields, keyed by field name
|
|
12
|
-
// def.mutation — root Mutation fields, keyed by field name
|
|
13
|
-
// def.servers — synthesised from the `endpoint` option (a schema carries
|
|
14
|
-
// no deployment URL, but transform/top.ts requires one)
|
|
15
|
-
// def.info — synthesised; SDL has no info block
|
|
16
|
-
//
|
|
17
|
-
// Type references are held as NAME STRINGS, never object pointers, so the
|
|
18
|
-
// result is acyclic and JSON-serialisable by construction — GraphQL type
|
|
19
|
-
// graphs are freely recursive (Issue.team.issues), and apidef writes
|
|
20
|
-
// `<def>.full.json` under the debug flag.
|
|
21
3
|
|
|
22
4
|
import { relativizePath } from '../utility'
|
|
23
5
|
|
|
@@ -114,8 +96,6 @@ function asIntrospection(source: string): any {
|
|
|
114
96
|
}
|
|
115
97
|
|
|
116
98
|
|
|
117
|
-
// Render a type reference to its GraphQL source form ('[Issue!]!') and its
|
|
118
|
-
// named form ('Issue'), plus the required/list flags the classifier keys on.
|
|
119
99
|
function describeType(G: any, gtype: any) {
|
|
120
100
|
const gqltype = String(gtype)
|
|
121
101
|
const named = G.getNamedType(gtype)
|
|
@@ -241,11 +221,6 @@ function rootFields(G: any, gtype: any): Record<string, GqlField> {
|
|
|
241
221
|
}
|
|
242
222
|
|
|
243
223
|
|
|
244
|
-
// Parse a GraphQL schema (SDL text or introspection JSON) into `def`.
|
|
245
|
-
//
|
|
246
|
-
// `opts.endpoint` is REQUIRED: a schema declares no deployment URL, but a
|
|
247
|
-
// usable SDK needs a base URL and transform/top.ts fails the build without
|
|
248
|
-
// `servers[0].url`.
|
|
249
224
|
async function parseGraphQL(
|
|
250
225
|
source: string,
|
|
251
226
|
meta: { file: string },
|
package/src/parse.ts
CHANGED
|
@@ -103,7 +103,6 @@ async function parseOpenAPI(source: any, _meta?: any) {
|
|
|
103
103
|
)
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
// Ensure components exists (Redocly used to add this automatically)
|
|
107
106
|
if (null == parsed.components) {
|
|
108
107
|
parsed.components = {}
|
|
109
108
|
}
|
|
@@ -121,6 +120,9 @@ async function parseOpenAPI(source: any, _meta?: any) {
|
|
|
121
120
|
parsed.paths = cleaned
|
|
122
121
|
}
|
|
123
122
|
|
|
123
|
+
// See docs/design/derived-names.md
|
|
124
|
+
normalizeColonPathParams(parsed, _meta)
|
|
125
|
+
|
|
124
126
|
// Single-pass: add x-ref properties and resolve $ref pointers together.
|
|
125
127
|
addXRefsAndResolve(parsed, parsed)
|
|
126
128
|
|
|
@@ -130,22 +132,6 @@ async function parseOpenAPI(source: any, _meta?: any) {
|
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
|
|
133
|
-
// Break reference cycles so the parsed spec stays JSON-serializable, WITHOUT
|
|
134
|
-
// destroying the structure sharing that $ref inlining deliberately creates.
|
|
135
|
-
//
|
|
136
|
-
// @voxgig/util's decircular() rebuilds the tree — it allocates a fresh object
|
|
137
|
-
// per *visit*, so a component reachable by k distinct paths is copied k times
|
|
138
|
-
// and the result is the tree-expansion of the DAG, size O(fanout^depth). That
|
|
139
|
-
// is catastrophic on exactly the shape real specs have (components reused
|
|
140
|
-
// across nesting levels): a 2.3 KB spec with 12 levels and 3 refs per level
|
|
141
|
-
// expanded to a 172 MB model, and 13 levels exhausted a 2 GB heap.
|
|
142
|
-
//
|
|
143
|
-
// Cycles are real here — inlining a self-referential schema makes the copy's
|
|
144
|
-
// `properties` the same object as the original's, so the copy contains
|
|
145
|
-
// itself — so they still have to be cut. This does it in place: only the edge
|
|
146
|
-
// that closes a cycle is replaced (with decircular's marker string, so the
|
|
147
|
-
// output shape is unchanged), every other node is visited exactly once and
|
|
148
|
-
// left shared. Linear in the number of distinct nodes.
|
|
149
135
|
function decycle(root: any) {
|
|
150
136
|
// Entry path of each node on the current ancestor chain; presence in this
|
|
151
137
|
// map is what identifies a back-edge. Nodes are removed on the way out, so
|
|
@@ -160,11 +146,6 @@ function decycle(root: any) {
|
|
|
160
146
|
if (null == node || 'object' !== typeof node) return
|
|
161
147
|
if (done.has(node)) return
|
|
162
148
|
|
|
163
|
-
// The YAML parser hands back null-prototype objects. decircular() used to
|
|
164
|
-
// launder them into plain objects as a side effect of rebuilding the tree;
|
|
165
|
-
// parse()'s result is public, so keep that contract (callers reasonably
|
|
166
|
-
// expect `hasOwnProperty` etc. on a parsed spec) rather than leaking the
|
|
167
|
-
// parser's internal shape now that nothing is rebuilt.
|
|
168
149
|
if (!Array.isArray(node) && null === Object.getPrototypeOf(node)) {
|
|
169
150
|
Object.setPrototypeOf(node, Object.prototype)
|
|
170
151
|
}
|
|
@@ -198,21 +179,6 @@ function decycle(root: any) {
|
|
|
198
179
|
}
|
|
199
180
|
|
|
200
181
|
|
|
201
|
-
// Single-pass tree walk that:
|
|
202
|
-
// 1. Preserves original $ref values as x-ref
|
|
203
|
-
// 2. Resolves $ref JSON pointers in-place
|
|
204
|
-
//
|
|
205
|
-
// NOTE: resolution inlines a shallow copy of the target ({ ...resolved }),
|
|
206
|
-
// so multiple references to the same component share that component's
|
|
207
|
-
// nested child objects. Downstream consumers must therefore treat the
|
|
208
|
-
// resolved schema as read-only — mutating an inlined sub-object would leak
|
|
209
|
-
// across every site that referenced the same component. (A deep clone is
|
|
210
|
-
// deliberately avoided: schemas can be self-referential, which would make
|
|
211
|
-
// cloning non-terminating.)
|
|
212
|
-
// Keywords sitting beside a `$ref` on the *referring* node. OpenAPI 3.1 and
|
|
213
|
-
// JSON Schema 2020-12 both allow them (`description`, `required`,
|
|
214
|
-
// constraints, ...) and they still apply, so inlining must not drop them.
|
|
215
|
-
// Applied over the resolved target, so the local statement wins.
|
|
216
182
|
function refSiblings(node: any): any {
|
|
217
183
|
const out: any = {}
|
|
218
184
|
for (const k of Object.keys(node)) {
|
|
@@ -271,27 +237,8 @@ function addXRefsAndResolve(obj: any, root: any, visited?: WeakSet<any>) {
|
|
|
271
237
|
}
|
|
272
238
|
|
|
273
239
|
|
|
274
|
-
// Follow a JSON pointer like "#/components/schemas/Planet".
|
|
275
|
-
//
|
|
276
|
-
// Alias components — `Foo: { $ref: '#/components/schemas/Bar' }` — are a
|
|
277
|
-
// normal OpenAPI idiom, so a pointer can land on another bare $ref node.
|
|
278
|
-
// Follow the chain to its end rather than returning the intermediate: the
|
|
279
|
-
// caller inlines `{ ...resolved }`, and a `$ref` *string* key in that spread
|
|
280
|
-
// is never followed by the object-valued recursion in addXRefsAndResolve, so
|
|
281
|
-
// stopping early yields a schema with no properties and every field is
|
|
282
|
-
// silently dropped. Whether that happened used to depend on whether `paths`
|
|
283
|
-
// or `components` came first in the document, because resolution reads a root
|
|
284
|
-
// the same walk is still mutating.
|
|
285
|
-
//
|
|
286
|
-
// `seen` holds pointer strings (not object identities) so a self- or
|
|
287
|
-
// mutually-referential alias cycle terminates instead of looping forever.
|
|
288
240
|
function resolvePointer(root: any, ref: string): any {
|
|
289
241
|
const seen = new Set<string>()
|
|
290
|
-
// Keywords sitting beside a `$ref` along the chain, outermost first.
|
|
291
|
-
// OpenAPI 3.1 / JSON Schema 2020-12 allow `$ref` to carry siblings
|
|
292
|
-
// (`description`, `required`, constraints, ...) and they still apply, so
|
|
293
|
-
// following the chain must not discard them. Merged onto the final target
|
|
294
|
-
// below, outermost last so the most specific alias wins.
|
|
295
242
|
const siblings: any[] = []
|
|
296
243
|
let current: any = undefined
|
|
297
244
|
let pointer = ref
|
|
@@ -370,3 +317,61 @@ function validateSource(kind: string, source: any, meta: { file: string }) {
|
|
|
370
317
|
export {
|
|
371
318
|
parse,
|
|
372
319
|
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
const METHODS = [
|
|
323
|
+
'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
// Rewrite `/a/:b/c` to `/a/{b}/c`, for a `:b` the path or one of its
|
|
328
|
+
// operations declares `in: path`. See docs/design/derived-names.md
|
|
329
|
+
function normalizeColonPathParams(parsed: any, meta?: any) {
|
|
330
|
+
if (null == parsed.paths || 'object' !== typeof parsed.paths) return
|
|
331
|
+
|
|
332
|
+
const renamed: string[] = []
|
|
333
|
+
const out: Record<string, any> = {}
|
|
334
|
+
|
|
335
|
+
for (const [path, item] of Object.entries<any>(parsed.paths)) {
|
|
336
|
+
if (!path.includes('/:')) {
|
|
337
|
+
out[path] = item
|
|
338
|
+
continue
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Every `in: path` name this path knows about: the path-level parameters
|
|
342
|
+
// plus each operation's own.
|
|
343
|
+
const declared = new Set<string>()
|
|
344
|
+
const collect = (params: any) => {
|
|
345
|
+
if (!Array.isArray(params)) return
|
|
346
|
+
for (const param of params) {
|
|
347
|
+
if (param && 'path' === param.in && 'string' === typeof param.name) {
|
|
348
|
+
declared.add(param.name)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (null != item) {
|
|
353
|
+
collect(item.parameters)
|
|
354
|
+
for (const method of METHODS) {
|
|
355
|
+
if (null != item[method]) collect(item[method].parameters)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const next = path.split('/').map((seg) =>
|
|
360
|
+
seg.startsWith(':') && declared.has(seg.slice(1)) ? '{' + seg.slice(1) + '}' : seg
|
|
361
|
+
).join('/')
|
|
362
|
+
|
|
363
|
+
if (next !== path) renamed.push(path)
|
|
364
|
+
out[next] = item
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
parsed.paths = out
|
|
368
|
+
|
|
369
|
+
if (0 < renamed.length && null != meta?.log?.info) {
|
|
370
|
+
meta.log.info({
|
|
371
|
+
point: 'path-colon-params',
|
|
372
|
+
count: renamed.length,
|
|
373
|
+
note: 'rewrote ' + renamed.length + ' colon-style path parameter(s) to' +
|
|
374
|
+
' OpenAPI brace form, e.g. ' + renamed[0]
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
}
|
package/src/resolved.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/* Copyright (c) 2024-2026 Voxgig Ltd, MIT License */
|
|
2
|
+
|
|
3
|
+
// See docs/design/resolved-spec-capability.md
|
|
4
|
+
|
|
5
|
+
import { graphqlInputTypes } from './transform/contract'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
const METHODS = [
|
|
9
|
+
'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
type OperationFacts = {
|
|
14
|
+
protocol: 'http' | 'graphql'
|
|
15
|
+
[key: string]: any
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
type ResolvedSpec = {
|
|
20
|
+
version: 1
|
|
21
|
+
kind: string
|
|
22
|
+
|
|
23
|
+
// The PARSED definition, not the bytes on disk: path keys are normalised
|
|
24
|
+
// during parse, so a consumer reading the file itself would miss lookups.
|
|
25
|
+
def: any
|
|
26
|
+
|
|
27
|
+
operation(method: string, path: string): OperationFacts | undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
// The single definition of a resolved operation, shared by contractTransform
|
|
32
|
+
// and by consumers of the capability, so the two cannot disagree.
|
|
33
|
+
function operationFacts(def: any, point: { method: string, orig: string }): OperationFacts | undefined {
|
|
34
|
+
const path = def?.paths?.[point.orig]
|
|
35
|
+
const method = path?.[String(point.method).toLowerCase()]
|
|
36
|
+
const graphql = def?.query?.[point.orig] || def?.mutation?.[point.orig]
|
|
37
|
+
|
|
38
|
+
if (!method && !graphql) return undefined
|
|
39
|
+
|
|
40
|
+
const facts: any = { protocol: graphql ? 'graphql' : 'http' }
|
|
41
|
+
|
|
42
|
+
if (graphql) {
|
|
43
|
+
facts.field = graphql
|
|
44
|
+
facts.types = graphqlInputTypes(graphql, def.types || {})
|
|
45
|
+
facts.typesScope = 'inputs'
|
|
46
|
+
return facts
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const key of ['operationId', 'requestBody', 'responses', 'consumes', 'produces']) {
|
|
50
|
+
if (undefined !== method[key]) facts[key] = method[key]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A path item may declare parameters shared by every operation under it.
|
|
54
|
+
facts.parameters = [...(path.parameters || []), ...(method.parameters || [])]
|
|
55
|
+
|
|
56
|
+
facts.security = method.security ?? def.security
|
|
57
|
+
facts.securitySource = method.security !== undefined ? 'operation' :
|
|
58
|
+
def.security !== undefined ? 'definition' : 'unspecified'
|
|
59
|
+
|
|
60
|
+
// swagger2 names this `securityDefinitions`.
|
|
61
|
+
facts.securitySchemes = def.components?.securitySchemes ?? def.securityDefinitions
|
|
62
|
+
|
|
63
|
+
facts.consumes ??= def.consumes
|
|
64
|
+
facts.produces ??= def.produces
|
|
65
|
+
|
|
66
|
+
return facts
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
// Every described operation, keyed 'METHOD path' as `point.contract.id` is.
|
|
71
|
+
function operationIndex(def: any): { [id: string]: OperationFacts } {
|
|
72
|
+
const out: { [id: string]: OperationFacts } = {}
|
|
73
|
+
|
|
74
|
+
for (const path of Object.keys(def?.paths || {})) {
|
|
75
|
+
for (const method of METHODS) {
|
|
76
|
+
if (null == def.paths[path]?.[method]) continue
|
|
77
|
+
const facts = operationFacts(def, { method, orig: path })
|
|
78
|
+
if (facts) out[method.toUpperCase() + ' ' + path] = facts
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const kind of ['query', 'mutation']) {
|
|
83
|
+
for (const field of Object.keys(def?.[kind] || {})) {
|
|
84
|
+
const facts = operationFacts(def, { method: 'POST', orig: field })
|
|
85
|
+
if (facts) out['POST ' + field] = facts
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return out
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
function makeResolved(kind: string, def: any): ResolvedSpec {
|
|
94
|
+
return {
|
|
95
|
+
version: 1,
|
|
96
|
+
kind,
|
|
97
|
+
def,
|
|
98
|
+
operation: (method: string, path: string) =>
|
|
99
|
+
operationFacts(def, { method, orig: path }),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
// Tolerates a missing context: apidef also runs outside a model build.
|
|
105
|
+
function publishResolved(ctx: any, kind: string, def: any): ResolvedSpec {
|
|
106
|
+
const resolved = makeResolved(kind, def)
|
|
107
|
+
if (null != ctx && 'object' === typeof ctx) {
|
|
108
|
+
ctx.state = ctx.state || {}
|
|
109
|
+
ctx.state.apidef = { ...(ctx.state.apidef || {}), resolved }
|
|
110
|
+
}
|
|
111
|
+
return resolved
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
function resolvedSpec(carrier: any): ResolvedSpec | undefined {
|
|
116
|
+
if (null == carrier || 'object' !== typeof carrier) return undefined
|
|
117
|
+
return carrier.state?.apidef?.resolved ??
|
|
118
|
+
carrier.ctx?.state?.apidef?.resolved ??
|
|
119
|
+
carrier.apidef?.resolved ??
|
|
120
|
+
undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
export type {
|
|
125
|
+
OperationFacts,
|
|
126
|
+
ResolvedSpec,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export {
|
|
130
|
+
METHODS,
|
|
131
|
+
operationFacts,
|
|
132
|
+
operationIndex,
|
|
133
|
+
makeResolved,
|
|
134
|
+
publishResolved,
|
|
135
|
+
resolvedSpec,
|
|
136
|
+
}
|
package/src/resolver.ts
CHANGED
|
@@ -15,8 +15,6 @@ async function resolveElements(
|
|
|
15
15
|
|
|
16
16
|
const { log, model, guide } = ctx
|
|
17
17
|
|
|
18
|
-
// TODO: model access should be via a utility that generates
|
|
19
|
-
// useful errors when the target is missing
|
|
20
18
|
const control = guide.control[kind][subkind]
|
|
21
19
|
|
|
22
20
|
const target = kind + '.' + subkind
|
package/src/transform/args.ts
CHANGED
|
@@ -65,9 +65,9 @@ const argsTransform: Transform = async function(
|
|
|
65
65
|
}
|
|
66
66
|
else {
|
|
67
67
|
const pathdef: PathDef = def.paths[mpoint.orig]
|
|
68
|
-
argdefs.push(...(pathdef
|
|
68
|
+
argdefs.push(...((pathdef as any)?.parameters ?? []))
|
|
69
69
|
|
|
70
|
-
const opdef: MethodDef = (pathdef as any)[mpoint.method.toLowerCase()]
|
|
70
|
+
const opdef: MethodDef = (pathdef as any)?.[mpoint.method.toLowerCase()]
|
|
71
71
|
argdefs.push(...(opdef?.parameters ?? []))
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -91,14 +91,6 @@ function graphqlFieldDef(def: any, mpoint: ModelPoint): any {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
|
|
94
|
-
// Map a GraphQL named type onto the JSON-schema-ish scalar names the
|
|
95
|
-
// existing arg/field typing understands.
|
|
96
|
-
//
|
|
97
|
-
// Only the built-in scalars have a known JSON shape. A custom scalar can be
|
|
98
|
-
// anything — JSON/JSONObject accept objects and arrays, DateTime is a
|
|
99
|
-
// string, Upload is a file handle — so anything unrecognised stays
|
|
100
|
-
// unconstrained rather than being wrongly advertised (and validated) as a
|
|
101
|
-
// string. ID and String are the two custom-free string cases.
|
|
102
94
|
function gqlScalarType(typeName: string): string | undefined {
|
|
103
95
|
return 'Int' === typeName ? 'integer' :
|
|
104
96
|
'Float' === typeName ? 'number' :
|
|
@@ -123,17 +115,9 @@ function resolveArgs(
|
|
|
123
115
|
const touchedKeys = new Set<string>()
|
|
124
116
|
|
|
125
117
|
each(argdefs, (argdef: ParameterDef) => {
|
|
126
|
-
// Spec name as written (e.g. `dataType`) is what the rename map is keyed
|
|
127
|
-
// by; the snakified form is the user-friendly runtime identifier.
|
|
128
118
|
const specName = normalizeFieldName(argdef.name)
|
|
129
119
|
const orig = depluralize(snakify(specName))
|
|
130
120
|
|
|
131
|
-
// A parameter with no name is not a parameter. This is what a DANGLING
|
|
132
|
-
// `$ref` looks like by the time it reaches here: the reference survives
|
|
133
|
-
// unresolved, `name` and `in` are both absent, and the arg would become
|
|
134
|
-
// a nameless `query` entry that every target then has to render. Ruby
|
|
135
|
-
// cannot: `Struct.new(:"")` raises at load and takes the whole SDK with
|
|
136
|
-
// it. Drop it and say which reference is missing.
|
|
137
121
|
if ('' === orig) {
|
|
138
122
|
const ref = (argdef as any)?.$ref
|
|
139
123
|
ctx?.warn?.({
|
|
@@ -185,13 +169,6 @@ function resolveArgs(
|
|
|
185
169
|
}
|
|
186
170
|
|
|
187
171
|
|
|
188
|
-
// OpenAPI lets specs advertise example values four ways:
|
|
189
|
-
// parameter.example (single value, OAS 3.0+)
|
|
190
|
-
// parameter.examples (named-example object, take first .value)
|
|
191
|
-
// parameter.schema.example (single value on the schema)
|
|
192
|
-
// parameter.schema.default (default value)
|
|
193
|
-
// Pick the first one we find so test generators can produce valid live
|
|
194
|
-
// requests even when the parameter is required and has no other source.
|
|
195
172
|
function resolveArgExample(argdef: any): any {
|
|
196
173
|
if (undefined !== argdef?.example) return argdef.example
|
|
197
174
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { each, camelify } from 'jostraca'
|
|
2
|
+
|
|
3
|
+
import type { TransformResult, Transform } from '../transform'
|
|
4
|
+
|
|
5
|
+
import { KIT } from '../types'
|
|
6
|
+
import type { KitModel } from '../types'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// See docs/design/derived-names.md
|
|
10
|
+
function deepempty(v: any): boolean {
|
|
11
|
+
if (null == v) return true
|
|
12
|
+
if ('object' !== typeof v) return false
|
|
13
|
+
const keys = Object.keys(v)
|
|
14
|
+
if (0 === keys.length) return true
|
|
15
|
+
return keys.every((k) => deepempty(v[k]))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
const casecollideTransform: Transform = async function(
|
|
20
|
+
ctx: any,
|
|
21
|
+
): Promise<TransformResult> {
|
|
22
|
+
const { apimodel, guide } = ctx
|
|
23
|
+
const kit: KitModel = apimodel.main[KIT]
|
|
24
|
+
|
|
25
|
+
const bylower: { [lower: string]: string[] } = {}
|
|
26
|
+
each(kit.entity, (_entity: any, entname: string) => {
|
|
27
|
+
const lower = String(camelify(entname)).toLowerCase()
|
|
28
|
+
bylower[lower] = bylower[lower] || []
|
|
29
|
+
bylower[lower].push(entname)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const dropped: string[] = []
|
|
33
|
+
|
|
34
|
+
for (const lower of Object.keys(bylower)) {
|
|
35
|
+
const group = bylower[lower].sort()
|
|
36
|
+
if (group.length < 2) continue
|
|
37
|
+
|
|
38
|
+
// See docs/design/derived-names.md
|
|
39
|
+
const opcount = (name: string) => {
|
|
40
|
+
const ops = (kit.entity as any)[name]?.op || {}
|
|
41
|
+
return Object.keys(ops).filter((opname) => !deepempty(ops[opname])).length
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const withops = group.filter(n => 0 < opcount(n))
|
|
45
|
+
const noops = group.filter(n => 0 === opcount(n))
|
|
46
|
+
|
|
47
|
+
if (0 === withops.length || 0 === noops.length) {
|
|
48
|
+
ctx.log.warn({
|
|
49
|
+
point: 'entity-case-collision',
|
|
50
|
+
entity: group,
|
|
51
|
+
note: 'entity names differ only by case and all carry operations: ' +
|
|
52
|
+
group.join(', ') + ' — the generated files collide on a ' +
|
|
53
|
+
'case-insensitive filesystem. Not resolved here: dropping one ' +
|
|
54
|
+
'would remove operations from the SDK.'
|
|
55
|
+
})
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const name of noops) {
|
|
60
|
+
delete (kit.entity as any)[name]
|
|
61
|
+
if (guide?.entity) delete guide.entity[name]
|
|
62
|
+
dropped.push(name)
|
|
63
|
+
ctx.log.info({
|
|
64
|
+
point: 'entity-case-collision-drop',
|
|
65
|
+
entity: name,
|
|
66
|
+
kept: withops,
|
|
67
|
+
note: 'dropped entity ' + name + ': it carries no operations and its ' +
|
|
68
|
+
'name differs only by case from ' + withops.join(', ') +
|
|
69
|
+
', so both would generate to one file.'
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { ok: true, msg: 0 === dropped.length ? '' : 'dropped: ' + dropped.join(',') }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
export {
|
|
79
|
+
casecollideTransform
|
|
80
|
+
}
|
|
@@ -1,21 +1,36 @@
|
|
|
1
|
-
//
|
|
2
|
-
// keywords from aontu unification and the model's empty-node cleanup.
|
|
1
|
+
// Point contracts. See docs/design/resolved-spec-capability.md
|
|
3
2
|
import type { Transform } from '../transform'
|
|
4
3
|
|
|
4
|
+
import { operationFacts } from '../resolved'
|
|
5
|
+
|
|
6
|
+
|
|
5
7
|
export function contractJSON(value: any): string {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
function walk(root: any, base: string): any {
|
|
9
|
+
// One memo per fact, so refs stay local to it.
|
|
10
|
+
const seen = new Map<any, string>()
|
|
11
|
+
function copy(v: any, path: string): any {
|
|
12
|
+
if (v === null || typeof v !== 'object') return v
|
|
13
|
+
if (seen.has(v)) return { $ref: seen.get(v) }
|
|
14
|
+
seen.set(v, path)
|
|
15
|
+
const out: any = Array.isArray(v) ? v.map((item, i) => copy(item, path + '/' + i)) : {}
|
|
16
|
+
if (!Array.isArray(v)) for (const k of Object.keys(v).sort()) {
|
|
17
|
+
if (!k.endsWith('$') && !k.startsWith('x-') && undefined !== v[k]) out[k] = copy(v[k], path + '/' + k.replace(/~/g, '~0').replace(/\//g, '~1'))
|
|
18
|
+
}
|
|
19
|
+
return out
|
|
14
20
|
}
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
return copy(root, base)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (null === value || 'object' !== typeof value || Array.isArray(value)) {
|
|
25
|
+
return JSON.stringify(walk(value, '#'))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const out: any = {}
|
|
29
|
+
for (const k of Object.keys(value).sort()) {
|
|
30
|
+
if (k.endsWith('$') || k.startsWith('x-') || undefined === value[k]) continue
|
|
31
|
+
out[k] = walk(value[k], '#/' + k.replace(/~/g, '~0').replace(/\//g, '~1'))
|
|
17
32
|
}
|
|
18
|
-
return JSON.stringify(
|
|
33
|
+
return JSON.stringify(out)
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
// An operation needs its argument types, including recursive input objects.
|
|
@@ -44,24 +59,11 @@ export const contractTransform: Transform = async (ctx: any) => {
|
|
|
44
59
|
const method = path?.[point.method.toLowerCase()]
|
|
45
60
|
const graphql = def.query?.[point.orig] || def.mutation?.[point.orig]
|
|
46
61
|
if (!method && !graphql) continue
|
|
47
|
-
const facts: any =
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
facts.invocation = point.graphql
|
|
53
|
-
} else {
|
|
54
|
-
for (const key of ['operationId', 'requestBody', 'responses', 'consumes', 'produces']) {
|
|
55
|
-
if (undefined !== method[key]) facts[key] = method[key]
|
|
56
|
-
}
|
|
57
|
-
facts.parameters = [...(path.parameters || []), ...(method.parameters || [])]
|
|
58
|
-
facts.security = method.security ?? def.security
|
|
59
|
-
facts.securitySource = method.security !== undefined ? 'operation' :
|
|
60
|
-
def.security !== undefined ? 'definition' : 'unspecified'
|
|
61
|
-
facts.securitySchemes = def.components?.securitySchemes ?? def.securityDefinitions
|
|
62
|
-
facts.consumes ??= def.consumes
|
|
63
|
-
facts.produces ??= def.produces
|
|
64
|
-
}
|
|
62
|
+
const facts: any = operationFacts(def, point)
|
|
63
|
+
if (null == facts) continue
|
|
64
|
+
|
|
65
|
+
// A property of the point, not of the definition.
|
|
66
|
+
if (graphql) facts.invocation = point.graphql
|
|
65
67
|
const guideOp = ctx.guide?.entity?.[entity.name]?.[graphql ? 'field' : 'path']?.[point.orig]?.op?.[op.name]
|
|
66
68
|
const hint = guideOp?.live
|
|
67
69
|
for (const key of ['requestBody', 'responses', 'parameters', 'security']) {
|
|
@@ -70,10 +72,18 @@ export const contractTransform: Transform = async (ctx: any) => {
|
|
|
70
72
|
;(facts.factSources ??= {})[key] = 'guide'
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
|
-
if (hint !== undefined)
|
|
75
|
+
if (hint !== undefined) {
|
|
76
|
+
facts.live = hint
|
|
77
|
+
point.live = hint
|
|
78
|
+
}
|
|
79
|
+
// Identity only; facts come from the capability. See
|
|
80
|
+
// docs/design/resolved-spec-capability.md
|
|
74
81
|
point.contract = { version: 1, id: point.method + ' ' + point.orig,
|
|
75
|
-
source: graphql ? 'graphql' : def.swagger ? 'swagger2' : 'openapi3'
|
|
76
|
-
|
|
82
|
+
source: graphql ? 'graphql' : def.swagger ? 'swagger2' : 'openapi3' }
|
|
83
|
+
|
|
84
|
+
if (ctx.opts?.contractJson) {
|
|
85
|
+
point.contract.json = contractJSON(facts)
|
|
86
|
+
}
|
|
77
87
|
}
|
|
78
88
|
}
|
|
79
89
|
}
|