@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.
Files changed (79) hide show
  1. package/dist/apidef.d.ts +4 -1
  2. package/dist/apidef.js +12 -34
  3. package/dist/apidef.js.map +1 -1
  4. package/dist/builder/entity/entity.js +0 -33
  5. package/dist/builder/entity/entity.js.map +1 -1
  6. package/dist/builder/entity/info.js +0 -4
  7. package/dist/builder/entity/info.js.map +1 -1
  8. package/dist/builder/flow.d.ts +4 -1
  9. package/dist/builder/flow.js +36 -27
  10. package/dist/builder/flow.js.map +1 -1
  11. package/dist/guide/graphql01.js +0 -55
  12. package/dist/guide/graphql01.js.map +1 -1
  13. package/dist/guide/guide.js +3 -126
  14. package/dist/guide/guide.js.map +1 -1
  15. package/dist/guide/heuristic01.js +40 -181
  16. package/dist/guide/heuristic01.js.map +1 -1
  17. package/dist/parse/graphql.js +0 -25
  18. package/dist/parse/graphql.js.map +1 -1
  19. package/dist/parse.js +51 -56
  20. package/dist/parse.js.map +1 -1
  21. package/dist/resolved.d.ts +23 -0
  22. package/dist/resolved.js +92 -0
  23. package/dist/resolved.js.map +1 -0
  24. package/dist/resolver.js +0 -2
  25. package/dist/resolver.js.map +1 -1
  26. package/dist/transform/args.js +2 -25
  27. package/dist/transform/args.js.map +1 -1
  28. package/dist/transform/casecollide.d.ts +3 -0
  29. package/dist/transform/casecollide.js +67 -0
  30. package/dist/transform/casecollide.js.map +1 -0
  31. package/dist/transform/contract.js +43 -38
  32. package/dist/transform/contract.js.map +1 -1
  33. package/dist/transform/entity.js +0 -56
  34. package/dist/transform/entity.js.map +1 -1
  35. package/dist/transform/field.js +1 -368
  36. package/dist/transform/field.js.map +1 -1
  37. package/dist/transform/flowstep.js +0 -49
  38. package/dist/transform/flowstep.js.map +1 -1
  39. package/dist/transform/graphql.js +0 -36
  40. package/dist/transform/graphql.js.map +1 -1
  41. package/dist/transform/operation.js +0 -23
  42. package/dist/transform/operation.js.map +1 -1
  43. package/dist/transform/top.js +1 -128
  44. package/dist/transform/top.js.map +1 -1
  45. package/dist/transform.js.map +1 -1
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/dist/types.d.ts +2 -0
  48. package/dist/types.js.map +1 -1
  49. package/dist/utility.d.ts +2 -1
  50. package/dist/utility.js +17 -316
  51. package/dist/utility.js.map +1 -1
  52. package/model/apidef.aon +20 -113
  53. package/model/guide.aon +1 -32
  54. package/package.json +5 -5
  55. package/src/apidef.ts +22 -33
  56. package/src/builder/entity/entity.ts +0 -33
  57. package/src/builder/entity/info.ts +0 -4
  58. package/src/builder/flow.ts +39 -24
  59. package/src/desc.ts +0 -22
  60. package/src/guide/graphql01.ts +0 -75
  61. package/src/guide/guide.ts +5 -127
  62. package/src/guide/heuristic01.ts +47 -182
  63. package/src/model.ts +6 -85
  64. package/src/parse/graphql.ts +0 -25
  65. package/src/parse.ts +61 -56
  66. package/src/resolved.ts +136 -0
  67. package/src/resolver.ts +0 -2
  68. package/src/transform/args.ts +2 -25
  69. package/src/transform/casecollide.ts +80 -0
  70. package/src/transform/contract.ts +44 -34
  71. package/src/transform/entity.ts +0 -56
  72. package/src/transform/field.ts +2 -368
  73. package/src/transform/flowstep.ts +0 -49
  74. package/src/transform/graphql.ts +0 -36
  75. package/src/transform/operation.ts +0 -23
  76. package/src/transform/top.ts +1 -131
  77. package/src/transform.ts +0 -1
  78. package/src/types.ts +4 -13
  79. package/src/utility.ts +18 -314
@@ -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
+ }
@@ -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
@@ -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.parameters ?? []))
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
- // Lossless point facts. JSON protects explicit empty contracts and schema
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
- const ancestors = new Map<any, string>()
7
- function copy(v: any, path: string): any {
8
- if (v === null || typeof v !== 'object') return v
9
- if (ancestors.has(v)) return { $ref: ancestors.get(v) }
10
- ancestors.set(v, path)
11
- const out: any = Array.isArray(v) ? v.map((item, i) => copy(item, path + '/' + i)) : {}
12
- if (!Array.isArray(v)) for (const k of Object.keys(v).sort()) {
13
- if (!k.endsWith('$') && !k.startsWith('x-') && undefined !== v[k]) out[k] = copy(v[k], path + '/' + k.replace(/~/g, '~0').replace(/\//g, '~1'))
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
- ancestors.delete(v)
16
- return out
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(copy(value, '#'))
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 = { protocol: graphql ? 'graphql' : 'http' }
48
- if (graphql) {
49
- facts.field = graphql
50
- facts.types = graphqlInputTypes(graphql, def.types || {})
51
- facts.typesScope = 'inputs'
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) facts.live = hint
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
- json: contractJSON(facts) }
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
  }