@voxgig/apidef 0.1.4 → 0.3.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/src/apidef.ts CHANGED
@@ -1,57 +1,133 @@
1
- /* Copyright (c) 2024 Richard Rodger, MIT License */
1
+ /* Copyright (c) 2024 Voxgig, MIT License */
2
2
 
3
3
  import * as Fs from 'node:fs'
4
-
5
4
  import Path from 'node:path'
6
5
 
7
6
 
8
7
  import { bundleFromString, createConfig } from '@redocly/openapi-core'
9
-
10
8
  import { FSWatcher } from 'chokidar'
11
-
12
9
  import { Aontu, Context } from 'aontu'
10
+ import Pino from 'pino'
11
+ import PinoPretty from 'pino-pretty'
12
+
13
+
13
14
 
14
- import { getx, each, camelify } from 'jostraca'
15
+
16
+ import {
17
+ resolveTransforms,
18
+ processTransforms,
19
+ fixName,
20
+ } from './transform'
15
21
 
16
22
 
17
23
  type ApiDefOptions = {
18
24
  fs?: any
25
+ pino?: ReturnType<typeof Pino>
26
+ debug?: boolean | string
19
27
  }
20
28
 
21
29
 
30
+ type ApiDefSpec = {
31
+ def: string
32
+ model: string,
33
+ kind: string,
34
+ meta: Record<string, any>,
35
+ }
22
36
 
23
37
 
24
38
  function ApiDef(opts: ApiDefOptions = {}) {
25
39
  const fs = opts.fs || Fs
40
+ let pino = opts.pino
41
+
42
+ if (null == pino) {
43
+ let pretty = PinoPretty({ sync: true })
44
+ const level = null == opts.debug ? 'info' :
45
+ true === opts.debug ? 'debug' :
46
+ 'string' == typeof opts.debug ? opts.debug :
47
+ 'info'
48
+
49
+ pino = Pino({
50
+ name: 'apidef',
51
+ level,
52
+ },
53
+ pretty
54
+ )
55
+ }
56
+
57
+
58
+ const log = pino.child({ cmp: 'apidef' })
26
59
 
27
60
 
28
61
  async function watch(spec: any) {
62
+ log.info({ point: 'watch-start' })
63
+ log.debug({ point: 'watch-spec', spec })
64
+
29
65
  await generate(spec)
30
66
 
31
67
  const fsw = new FSWatcher()
32
68
 
33
69
  fsw.on('change', (...args: any[]) => {
70
+ log.trace({ watch: 'change', file: args[0] })
34
71
  generate(spec)
35
72
  })
36
73
 
74
+ log.trace({ watch: 'add', what: 'def', file: spec.def })
37
75
  fsw.add(spec.def)
76
+
77
+ log.trace({ watch: 'add', what: 'guide', file: spec.guilde })
78
+ fsw.add(spec.guide)
38
79
  }
39
80
 
40
81
 
41
- async function generate(spec: any) {
82
+ async function generate(spec: ApiDefSpec) {
83
+ const start = Date.now()
84
+
85
+ log.info({ point: 'generate-start', start })
86
+ log.debug({ point: 'generate-spec', spec })
87
+
88
+ // TODO: Validate spec
89
+ const ctx = {
90
+ log,
91
+ spec,
92
+ guide: {},
93
+ opts,
94
+ util: { fixName },
95
+ defpath: Path.dirname(spec.def)
96
+ }
97
+
42
98
  const guide = await resolveGuide(spec, opts)
43
- const transform = resolveTranform(spec, guide, opts)
99
+ log.debug({ point: 'guide', guide })
44
100
 
45
- const source = fs.readFileSync(spec.def, 'utf8')
101
+ ctx.guide = guide
102
+ const transformSpec = await resolveTransforms(ctx)
103
+ log.debug({ point: 'transform', spec: transformSpec })
104
+
105
+
106
+ let source
107
+ try {
108
+ source = fs.readFileSync(spec.def, 'utf8')
109
+ }
110
+ catch (err: any) {
111
+ log.error({ read: 'fail', what: 'def', file: spec.def, err })
112
+ throw err
113
+ }
46
114
 
47
- const modelBasePath = Path.dirname(spec.model)
48
115
 
49
116
  const config = await createConfig({})
50
- const bundle = await bundleFromString({
51
- source,
52
- config,
53
- dereference: true,
54
- })
117
+ let bundle
118
+
119
+ try {
120
+ bundle = await bundleFromString({
121
+ source,
122
+ config,
123
+ dereference: true,
124
+ })
125
+ }
126
+ catch (err: any) {
127
+ log.error({ parse: 'fail', what: 'openapi', file: spec.def, err })
128
+ throw err
129
+ }
130
+
55
131
 
56
132
  const model = {
57
133
  main: {
@@ -64,11 +140,18 @@ function ApiDef(opts: ApiDefOptions = {}) {
64
140
 
65
141
  try {
66
142
  const def = bundle.bundle.parsed
67
- // console.dir(def, { depth: null })
68
- transform(def, model)
143
+ const processResult = await processTransforms(ctx, transformSpec, model, def)
144
+
145
+ if (!processResult.ok) {
146
+ log.error({ process: 'fail', what: 'transform', result: processResult })
147
+ throw new Error('Transform failed: ' + processResult.msg)
148
+ }
149
+ else {
150
+ log.debug({ process: 'result', what: 'transform', result: processResult })
151
+ }
69
152
  }
70
153
  catch (err: any) {
71
- console.log('APIDEF ERROR', err)
154
+ log.error({ process: 'fail', what: 'transform', err })
72
155
  throw err
73
156
  }
74
157
 
@@ -76,23 +159,35 @@ function ApiDef(opts: ApiDefOptions = {}) {
76
159
  let modelSrc = JSON.stringify(modelapi, null, 2)
77
160
  modelSrc = modelSrc.substring(1, modelSrc.length - 1)
78
161
 
79
- fs.writeFileSync(
80
- spec.model,
81
- modelSrc
82
- )
162
+ writeChanged('api-model', spec.model, modelSrc)
83
163
 
84
164
 
165
+ const modelBasePath = Path.dirname(spec.model)
85
166
  const defFilePath = Path.join(modelBasePath, 'def.jsonic')
86
167
 
87
168
  const modelDef = { main: { def: model.main.def } }
88
169
  let modelDefSrc = JSON.stringify(modelDef, null, 2)
89
170
  modelDefSrc = modelDefSrc.substring(1, modelDefSrc.length - 1)
90
171
 
91
- fs.writeFileSync(
92
- defFilePath,
93
- modelDefSrc
94
- )
172
+ writeChanged('def-model', defFilePath, modelDefSrc)
173
+
174
+ /*
175
+ let existingSrc: string = ''
176
+ if (fs.existsSync(defFilePath)) {
177
+ existingSrc = fs.readFileSync(defFilePath, 'utf8')
178
+ }
95
179
 
180
+ let writeModelDef = existingSrc !== modelDefSrc
181
+ // console.log('APIDEF', writeModelDef)
182
+
183
+ // Only write the model def if it has changed
184
+ if (writeModelDef) {
185
+ fs.writeFileSync(
186
+ defFilePath,
187
+ modelDefSrc
188
+ )
189
+ }
190
+ */
96
191
 
97
192
  return {
98
193
  ok: true,
@@ -101,6 +196,43 @@ function ApiDef(opts: ApiDefOptions = {}) {
101
196
  }
102
197
 
103
198
 
199
+
200
+ function writeChanged(what: string, path: string, content: string) {
201
+ let exists = false
202
+ let changed = false
203
+ let action = ''
204
+ try {
205
+ let existingContent: string = ''
206
+ exists = fs.existsSync(path)
207
+
208
+ if (exists) {
209
+ action = 'read'
210
+ existingContent = fs.readFileSync(path, 'utf8')
211
+ }
212
+
213
+ changed = existingContent !== content
214
+
215
+ log.info({
216
+ write: 'file', what, skip: !changed, exists, changed,
217
+ contentLength: content.length, file: path
218
+ })
219
+
220
+ if (changed) {
221
+ action = 'write'
222
+ fs.writeFileSync(path, content)
223
+ }
224
+ }
225
+ catch (err: any) {
226
+ log.error({
227
+ fail: action, what, file: path, exists, changed,
228
+ contentLength: content.length, err
229
+ })
230
+ throw err
231
+ }
232
+ }
233
+
234
+
235
+
104
236
  async function resolveGuide(spec: any, _opts: any) {
105
237
  if (null == spec.guide) {
106
238
  spec.guide = spec.def + '-guide.jsonic'
@@ -109,34 +241,58 @@ function ApiDef(opts: ApiDefOptions = {}) {
109
241
  const path = Path.normalize(spec.guide)
110
242
  let src: string
111
243
 
112
- // console.log('APIDEF resolveGuide', path)
244
+ let action = ''
245
+ let exists = false
246
+ try {
113
247
 
114
- if (fs.existsSync(path)) {
115
- src = fs.readFileSync(path, 'utf8')
116
- }
117
- else {
118
- src = `
248
+ action = 'exists'
249
+ let exists = fs.existsSync(path)
250
+
251
+ log.debug({ read: 'file', what: 'guide', file: path, exists })
252
+
253
+ if (exists) {
254
+ action = 'read'
255
+ src = fs.readFileSync(path, 'utf8')
256
+ }
257
+ else {
258
+ src = `
119
259
  # API Specification Transform Guide
120
260
 
121
- @"node_modules/@voxgig/apidef/model/guide.jsonic"
261
+ @"@voxgig/apidef/model/guide.jsonic"
122
262
 
123
263
  guide: entity: {
124
264
 
125
265
  }
126
266
 
267
+ guide: control: transform: openapi: order: \`
268
+ top,
269
+ entity,
270
+ operation,
271
+ field,
272
+ manual,
273
+ \`
274
+
127
275
  `
128
- fs.writeFileSync(path, src)
276
+ action = 'write'
277
+ fs.writeFileSync(path, src)
278
+ }
279
+ }
280
+ catch (err: any) {
281
+ log.error({ fail: action, what: 'guide', file: path, exists, err })
282
+ throw err
129
283
  }
130
284
 
131
- const aopts = {}
285
+
286
+ const aopts = { path }
132
287
  const root = Aontu(src, aopts)
133
288
  const hasErr = root.err && 0 < root.err.length
134
289
 
135
- // TODO: collect all errors
136
290
  if (hasErr) {
137
- // console.log(root.err)
138
- // throw new Error(root.err[0])
139
- throw root.err[0].err
291
+ const err: any = new Error('Guide parse error:\n' +
292
+ (root.err.map((pe: any) => pe.msg)).join('\n'))
293
+ log.error({ fail: 'parse', what: 'guide', file: path, err })
294
+ err.errs = () => root.err
295
+ throw err
140
296
  }
141
297
 
142
298
  let genctx = new Context({ root })
@@ -144,16 +300,29 @@ guide: entity: {
144
300
 
145
301
  // TODO: collect all errors
146
302
  if (genctx.err && 0 < genctx.err.length) {
147
- // console.log(genctx.err)
148
- throw new Error(JSON.stringify(genctx.err[0]))
303
+ const err: any = new Error('Guide build error:\n' +
304
+ (genctx.err.map((pe: any) => pe.msg)).join('\n'))
305
+ log.error({ fail: 'build', what: 'guide', file: path, err })
306
+ err.errs = () => genctx.err
307
+ throw err
149
308
  }
150
309
 
151
- // console.log('GUIDE')
152
- // console.dir(guide, { depth: null })
153
-
154
310
  const pathParts = Path.parse(path)
155
311
  spec.guideModelPath = Path.join(pathParts.dir, pathParts.name + '.json')
156
- fs.writeFileSync(spec.guideModelPath, JSON.stringify(guide, null, 2))
312
+
313
+ const updatedSrc = JSON.stringify(guide, null, 2)
314
+
315
+ writeChanged('guide-model', spec.guideModelPath, updatedSrc)
316
+
317
+ // console.log('APIDEF resolveGuide write', spec.guideModelPath, src !== updatedSrc)
318
+ // let existingSrc = ''
319
+ // if (fs.existsSync(spec.guideModelPath)) {
320
+ // existingSrc = fs.readFileSync(spec.guideModelPath, 'utf8')
321
+ // }
322
+
323
+ // if (existingSrc !== updatedSrc) {
324
+ // fs.writeFileSync(spec.guideModelPath, updatedSrc)
325
+ // }
157
326
 
158
327
  return guide
159
328
  }
@@ -168,195 +337,6 @@ guide: entity: {
168
337
 
169
338
 
170
339
 
171
-
172
- function resolveTranform(spec: any, guide: any, opts: any) {
173
- return makeOpenAPITransform(spec, guide, opts)
174
- }
175
-
176
-
177
- function extractFields(properties: any) {
178
- const fieldMap = each(properties)
179
- .reduce((a: any, p: any) => (a[p.key$] =
180
- { name: p.key$, kind: camelify(p.type) }, a), {})
181
- return fieldMap
182
- }
183
-
184
-
185
- function fixName(base: any, name: string, prop = 'name') {
186
- base[prop.toLowerCase()] = name.toLowerCase()
187
- base[camelify(prop)] = camelify(name)
188
- base[prop.toUpperCase()] = name.toUpperCase()
189
- }
190
-
191
-
192
- function makeOpenAPITransform(spec: any, guideModel: any, opts: any) {
193
-
194
- const paramBuilder = (paramMap: any, paramDef: any,
195
- entityModel: any, pathdef: any,
196
- op: any, path: any, entity: any, model: any) => {
197
-
198
- paramMap[paramDef.name] = {
199
- required: paramDef.required
200
- }
201
- fixName(paramMap[paramDef.name], paramDef.name)
202
-
203
- const type = paramDef.schema ? paramDef.schema.type : paramDef.type
204
- fixName(paramMap[paramDef.name], type, 'type')
205
- }
206
-
207
-
208
- const queryBuilder = (queryMap: any, queryDef: any,
209
- entityModel: any, pathdef: any,
210
- op: any, path: any, entity: any, model: any) => {
211
- queryMap[queryDef.name] = {
212
- required: queryDef.required
213
- }
214
- fixName(queryMap[queryDef.name], queryDef.name)
215
-
216
- const type = queryDef.schema ? queryDef.schema.type : queryDef.type
217
- fixName(queryMap[queryDef.name], type, 'type')
218
- }
219
-
220
-
221
- const opBuilder: any = {
222
- any: (entityModel: any, pathdef: any, op: any, path: any, entity: any, model: any) => {
223
- const em = entityModel.op[op.key$] = {
224
- path: path.key$,
225
- method: op.val$,
226
- param: {},
227
- query: {},
228
- }
229
- fixName(em, op.key$)
230
-
231
- // Params are in the path
232
- if (0 < path.params.length) {
233
- let params = getx(pathdef[op.val$], 'parameters?in=path') || []
234
- if (Array.isArray(params)) {
235
- params.reduce((a: any, p: any) =>
236
- (paramBuilder(a, p, entityModel, pathdef, op, path, entity, model), a), em.param)
237
- }
238
- }
239
-
240
- // Queries are after the ?
241
- let queries = getx(pathdef[op.val$], 'parameters?in!=path') || []
242
- if (Array.isArray(queries)) {
243
- queries.reduce((a: any, p: any) =>
244
- (queryBuilder(a, p, entityModel, pathdef, op, path, entity, model), a), em.query)
245
- }
246
-
247
- return em
248
- },
249
-
250
-
251
- list: (entityModel: any, pathdef: any, op: any, path: any, entity: any, model: any) => {
252
- return opBuilder.any(entityModel, pathdef, op, path, entity, model)
253
- },
254
-
255
- load: (entityModel: any, pathdef: any, op: any, path: any, entity: any, model: any) => {
256
- return opBuilder.any(entityModel, pathdef, op, path, entity, model)
257
- },
258
-
259
- create: (entityModel: any, pathdef: any, op: any, path: any, entity: any, model: any) => {
260
- return opBuilder.any(entityModel, pathdef, op, path, entity, model)
261
- },
262
-
263
- save: (entityModel: any, pathdef: any, op: any, path: any, entity: any, model: any) => {
264
- return opBuilder.any(entityModel, pathdef, op, path, entity, model)
265
- },
266
-
267
- remove: (entityModel: any, pathdef: any, op: any, path: any, entity: any, model: any) => {
268
- return opBuilder.any(entityModel, pathdef, op, path, entity, model)
269
- },
270
-
271
- }
272
-
273
-
274
- function fieldbuild(
275
- entityModel: any, pathdef: any, op: any, path: any, entity: any, model: any
276
- ) {
277
- // console.log(pathdef)
278
-
279
- let fieldSets = getx(pathdef.get, 'responses 200 content "application/json" schema')
280
-
281
- if (fieldSets) {
282
- if (Array.isArray(fieldSets.allOf)) {
283
- fieldSets = fieldSets.allOf
284
- }
285
- else if (fieldSets.properties) {
286
- fieldSets = [fieldSets]
287
- }
288
- }
289
-
290
- each(fieldSets, (fieldSet: any) => {
291
- each(fieldSet.properties, (property: any) => {
292
- // console.log(property)
293
-
294
- const field =
295
- (entityModel.field[property.key$] = entityModel.field[property.key$] || {})
296
-
297
- field.name = property.key$
298
- fixName(field, field.name)
299
-
300
- field.type = property.type
301
- fixName(field, field.type, 'type')
302
-
303
- field.short = property.description
304
- })
305
- })
306
- }
307
-
308
-
309
- return function OpenAPITransform(def: any, model: any) {
310
- fixName(model.main.api, spec.meta.name)
311
-
312
- // console.log('OpenAPITransform', guideModel)
313
-
314
- model.main.def.desc = def.info.description
315
-
316
-
317
- each(guideModel.guide.entity, (entity: any) => {
318
- // console.log('ENTITY', entity)
319
-
320
- const entityModel: any = model.main.api.entity[entity.key$] = {
321
- op: {},
322
- field: {},
323
- cmd: {},
324
- }
325
-
326
- fixName(entityModel, entity.key$)
327
-
328
- each(entity.path, (path: any) => {
329
- const pathdef = def.paths[path.key$]
330
-
331
- if (null == pathdef) {
332
- throw new Error('APIDEF: path not found in OpenAPI: ' + path.key$ +
333
- ' (entity: ' + entity.name + ')')
334
- }
335
-
336
- path.parts = path.key$.split('/')
337
- path.params = path.parts
338
- .filter((p: string) => p.startsWith('{'))
339
- .map((p: string) => p.substring(1, p.length - 1))
340
-
341
- // console.log('ENTITY-PATH', entity, path)
342
-
343
- each(path.op, (op: any) => {
344
- const opbuild = opBuilder[op.key$]
345
-
346
- if (opbuild) {
347
- opbuild(entityModel, pathdef, op, path, entity, model)
348
- }
349
-
350
- if ('load' === op.key$) {
351
- fieldbuild(entityModel, pathdef, op, path, entity, model)
352
- }
353
- })
354
- })
355
- })
356
- }
357
- }
358
-
359
-
360
340
  export type {
361
341
  ApiDefOptions,
362
342
  }
@@ -0,0 +1,54 @@
1
+
2
+
3
+ import { each } from 'jostraca'
4
+
5
+ import type { TransformCtx, TransformSpec } from '../transform'
6
+
7
+ import { fixName } from '../transform'
8
+
9
+
10
+
11
+ async function entityTransform(ctx: TransformCtx, tspec: TransformSpec, model: any, def: any) {
12
+ const { guide: { guide } } = ctx
13
+ let msg = ''
14
+
15
+ each(guide.entity, (guideEntity: any) => {
16
+
17
+ const entityModel: any = model.main.api.entity[guideEntity.key$] = {
18
+ op: {},
19
+ field: {},
20
+ cmd: {},
21
+ id: {
22
+ name: 'id',
23
+ field: 'id',
24
+ }
25
+ }
26
+
27
+ fixName(entityModel, guideEntity.key$)
28
+
29
+ each(guideEntity.path, (guidePath: any) => {
30
+ const pathdef = def.paths[guidePath.key$]
31
+
32
+ if (null == pathdef) {
33
+ throw new Error('APIDEF: path not found in OpenAPI: ' + guidePath.key$ +
34
+ ' (entity: ' + guideEntity.name + ')')
35
+ }
36
+
37
+ guidePath.parts$ = guidePath.key$.split('/')
38
+ guidePath.params$ = guidePath.parts$
39
+ .filter((p: string) => p.startsWith('{'))
40
+ .map((p: string) => p.substring(1, p.length - 1))
41
+
42
+ })
43
+
44
+ msg += guideEntity.name + ' '
45
+
46
+ })
47
+
48
+ return { ok: true, msg }
49
+ }
50
+
51
+
52
+ export {
53
+ entityTransform
54
+ }