massimo-cli 0.0.1 → 0.1.2

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/index.js ADDED
@@ -0,0 +1,537 @@
1
+ #! /usr/bin/env node
2
+
3
+ import { access, mkdir, readFile, rm, writeFile } from 'fs/promises'
4
+ import graphql from 'graphql'
5
+ import helpMe from 'help-me'
6
+ import parseArgs from 'minimist'
7
+ import { join } from 'path'
8
+ import pino from 'pino'
9
+ import pinoPretty from 'pino-pretty'
10
+ import { getGlobalDispatcher, interceptors, request } from 'undici'
11
+ import YAML from 'yaml'
12
+ import { processFrontendOpenAPI } from './lib/frontend-openapi-generator.js'
13
+ import { processGraphQL } from './lib/graphql-generator.js'
14
+ import { processOpenAPI } from './lib/openapi-generator.js'
15
+
16
+ function parseFile (content) {
17
+ let parsed = false
18
+ const toTry = [JSON.parse, YAML.parse]
19
+ for (const fn of toTry) {
20
+ try {
21
+ parsed = fn(content)
22
+ } catch (err) {
23
+ // do nothing
24
+ }
25
+ }
26
+ return parsed
27
+ }
28
+
29
+ export async function isFileAccessible (filename) {
30
+ try {
31
+ await access(filename)
32
+ return true
33
+ } catch (err) {
34
+ return false
35
+ }
36
+ }
37
+
38
+ export async function createDirectory (path, empty = false) {
39
+ if (empty) {
40
+ await rm(path, { force: true, recursive: true })
41
+ }
42
+
43
+ return mkdir(path, { recursive: true, maxRetries: 10, retryDelay: 1000 })
44
+ }
45
+
46
+ async function writeOpenAPIClient (
47
+ folder,
48
+ name,
49
+ text,
50
+ generateImplementation,
51
+ typesOnly,
52
+ fullRequest,
53
+ fullResponse,
54
+ optionalHeaders,
55
+ validateResponse,
56
+ isFrontend,
57
+ language,
58
+ typesComment,
59
+ logger,
60
+ withCredentials,
61
+ propsOptional
62
+ ) {
63
+ await createDirectory(folder)
64
+
65
+ // TODO deal with yaml
66
+ const schema = parseFile(text)
67
+ if (!schema) {
68
+ throw new Error('Cannot parse OpenAPI file. Please make sure is a JSON or a YAML file.')
69
+ }
70
+ if (!typesOnly) {
71
+ await writeFile(join(folder, `${name}.openapi.json`), JSON.stringify(schema, null, 2))
72
+ }
73
+
74
+ if (isFrontend) {
75
+ const { types, implementation } = processFrontendOpenAPI({
76
+ schema,
77
+ name,
78
+ fullRequest,
79
+ fullResponse,
80
+ language,
81
+ logger,
82
+ withCredentials,
83
+ propsOptional
84
+ })
85
+ await writeFile(join(folder, `${name}-types.d.ts`), types)
86
+ if (generateImplementation) {
87
+ const extension = language === 'js' ? 'js' : 'ts'
88
+ await writeFile(join(folder, `${name}.${extension}`), implementation)
89
+ }
90
+ } else {
91
+ const { types, implementation } = processOpenAPI({
92
+ schema,
93
+ name,
94
+ fullResponse,
95
+ fullRequest,
96
+ optionalHeaders,
97
+ validateResponse,
98
+ typesComment,
99
+ propsOptional
100
+ })
101
+ await writeFile(join(folder, `${name}.d.ts`), types)
102
+ if (generateImplementation) {
103
+ await writeFile(join(folder, `${name}.js`), implementation)
104
+ }
105
+
106
+ if (!typesOnly) {
107
+ await writeFile(join(folder, 'package.json'), getPackageJSON({ name, generateImplementation }))
108
+ }
109
+ }
110
+ }
111
+
112
+ async function writeGraphQLClient (folder, name, schema, url, generateImplementation) {
113
+ await createDirectory(folder, { recursive: true })
114
+ const { types, implementation } = processGraphQL({ schema, name, folder, url })
115
+ const clientSchema = graphql.buildClientSchema(schema)
116
+ const sdl = graphql.printSchema(clientSchema)
117
+ await writeFile(join(folder, `${name}.schema.graphql`), sdl)
118
+ await writeFile(join(folder, `${name}.d.ts`), types)
119
+ if (generateImplementation) {
120
+ await writeFile(join(folder, `${name}.js`), implementation)
121
+ }
122
+ await writeFile(join(folder, 'package.json'), getPackageJSON({ name, generateImplementation }))
123
+ }
124
+
125
+ async function downloadAndWriteOpenAPI (
126
+ logger,
127
+ url,
128
+ folder,
129
+ name,
130
+ generateImplementation,
131
+ typesOnly,
132
+ fullRequest,
133
+ fullResponse,
134
+ optionalHeaders,
135
+ validateResponse,
136
+ isFrontend,
137
+ language,
138
+ urlAuthHeaders,
139
+ typesComment,
140
+ withCredentials,
141
+ propsOptional,
142
+ retryTimeoutMs
143
+ ) {
144
+ logger.debug(`Trying to download OpenAPI schema from ${url}`)
145
+ let requestOptions
146
+ if (urlAuthHeaders) {
147
+ try {
148
+ requestOptions = { headers: JSON.parse(urlAuthHeaders) }
149
+ } catch (err) {
150
+ logger.error(err)
151
+ }
152
+ }
153
+
154
+ const dispatcher = retryTimeoutMs
155
+ ? getGlobalDispatcher().compose([interceptors.retry({ minTimeout: retryTimeoutMs })])
156
+ : undefined
157
+ const res = await request(url, { ...requestOptions, dispatcher })
158
+ if (res.statusCode === 200) {
159
+ // we are OpenAPI
160
+ const text = await res.body.text()
161
+ try {
162
+ await writeOpenAPIClient(
163
+ folder,
164
+ name,
165
+ text,
166
+ generateImplementation,
167
+ typesOnly,
168
+ fullRequest,
169
+ fullResponse,
170
+ optionalHeaders,
171
+ validateResponse,
172
+ isFrontend,
173
+ language,
174
+ typesComment,
175
+ logger,
176
+ withCredentials,
177
+ propsOptional
178
+ )
179
+ /* c8 ignore next 3 */
180
+ } catch (err) {
181
+ logger.error(err)
182
+ return false
183
+ }
184
+ return 'openapi'
185
+ }
186
+ res.body.resume()
187
+
188
+ return false
189
+ }
190
+
191
+ async function downloadAndWriteGraphQL (logger, url, folder, name, generateImplementation) {
192
+ logger.debug(`Trying to download GraphQL schema from ${url}`)
193
+ const query = graphql.getIntrospectionQuery()
194
+ const res = await request(url, {
195
+ method: 'POST',
196
+ headers: {
197
+ 'content-type': 'application/json'
198
+ },
199
+ body: JSON.stringify({
200
+ query
201
+ })
202
+ })
203
+
204
+ const text = await res.body.text()
205
+
206
+ if (res.statusCode !== 200) {
207
+ return false
208
+ }
209
+
210
+ const { data: schema } = JSON.parse(text)
211
+ await writeGraphQLClient(folder, name, schema, url, generateImplementation)
212
+ return 'graphql'
213
+ }
214
+
215
+ async function readFromFileAndWrite (
216
+ logger,
217
+ file,
218
+ folder,
219
+ name,
220
+ generateImplementation,
221
+ typesOnly,
222
+ fullRequest,
223
+ fullResponse,
224
+ optionalHeaders,
225
+ validateResponse,
226
+ isFrontend,
227
+ language,
228
+ typesComment,
229
+ withCredentials,
230
+ propsOptional
231
+ ) {
232
+ logger.info(`Trying to read schema from file ${file}`)
233
+ const text = await readFile(file, 'utf8')
234
+ // try OpenAPI first
235
+ try {
236
+ await writeOpenAPIClient(
237
+ folder,
238
+ name,
239
+ text,
240
+ generateImplementation,
241
+ typesOnly,
242
+ fullRequest,
243
+ fullResponse,
244
+ optionalHeaders,
245
+ validateResponse,
246
+ isFrontend,
247
+ language,
248
+ typesComment,
249
+ logger,
250
+ withCredentials,
251
+ propsOptional
252
+ )
253
+ return 'openapi'
254
+ } catch (err) {
255
+ logger.error(err, `Error parsing OpenAPI definition: "${err.message}". Trying with GraphQL`)
256
+ // try GraphQL
257
+ const schema = graphql.buildSchema(text)
258
+ const introspectionResult = graphql.introspectionFromSchema(schema)
259
+
260
+ // dummy URL
261
+ await writeGraphQLClient(folder, name, introspectionResult, 'http://localhost:3042/graphql', generateImplementation)
262
+ return 'graphql'
263
+ }
264
+ }
265
+ async function downloadAndProcess (options) {
266
+ const {
267
+ url,
268
+ name,
269
+ folder,
270
+ logger,
271
+ typesOnly,
272
+ fullRequest,
273
+ fullResponse,
274
+ optionalHeaders,
275
+ validateResponse,
276
+ isFrontend,
277
+ language,
278
+ type,
279
+ urlAuthHeaders,
280
+ typesComment,
281
+ withCredentials,
282
+ propsOptional,
283
+ retryTimeoutMs
284
+ } = options
285
+
286
+ const generateImplementation = options.generateImplementation
287
+
288
+ let found = false
289
+ const toTry = []
290
+ if (url.startsWith('http')) {
291
+ if (type === 'openapi') {
292
+ toTry.push(
293
+ downloadAndWriteOpenAPI.bind(
294
+ null,
295
+ logger,
296
+ url + '/documentation/json',
297
+ folder,
298
+ name,
299
+ generateImplementation,
300
+ typesOnly,
301
+ fullRequest,
302
+ fullResponse,
303
+ optionalHeaders,
304
+ validateResponse,
305
+ isFrontend,
306
+ language,
307
+ urlAuthHeaders,
308
+ typesComment,
309
+ withCredentials,
310
+ propsOptional,
311
+ retryTimeoutMs
312
+ )
313
+ )
314
+ toTry.push(
315
+ downloadAndWriteOpenAPI.bind(
316
+ null,
317
+ logger,
318
+ url,
319
+ folder,
320
+ name,
321
+ generateImplementation,
322
+ typesOnly,
323
+ fullRequest,
324
+ fullResponse,
325
+ optionalHeaders,
326
+ validateResponse,
327
+ isFrontend,
328
+ language,
329
+ urlAuthHeaders,
330
+ typesComment,
331
+ withCredentials,
332
+ propsOptional,
333
+ retryTimeoutMs
334
+ )
335
+ )
336
+ } else if (options.type === 'graphql') {
337
+ toTry.push(
338
+ downloadAndWriteGraphQL.bind(null, logger, url + '/graphql', folder, name, generateImplementation, typesOnly)
339
+ )
340
+ toTry.push(downloadAndWriteGraphQL.bind(null, logger, url, folder, name, generateImplementation, typesOnly))
341
+ } else {
342
+ // add download functions only if it's an URL
343
+ toTry.push(
344
+ downloadAndWriteOpenAPI.bind(
345
+ null,
346
+ logger,
347
+ url + '/documentation/json',
348
+ folder,
349
+ name,
350
+ generateImplementation,
351
+ typesOnly,
352
+ fullRequest,
353
+ fullResponse,
354
+ optionalHeaders,
355
+ validateResponse,
356
+ isFrontend,
357
+ language,
358
+ urlAuthHeaders,
359
+ typesComment,
360
+ withCredentials,
361
+ propsOptional,
362
+ retryTimeoutMs
363
+ )
364
+ )
365
+ toTry.push(
366
+ downloadAndWriteGraphQL.bind(null, logger, url + '/graphql', folder, name, generateImplementation, typesOnly)
367
+ )
368
+ toTry.push(
369
+ downloadAndWriteOpenAPI.bind(
370
+ null,
371
+ logger,
372
+ url,
373
+ folder,
374
+ name,
375
+ generateImplementation,
376
+ typesOnly,
377
+ fullRequest,
378
+ fullResponse,
379
+ optionalHeaders,
380
+ validateResponse,
381
+ isFrontend,
382
+ language,
383
+ urlAuthHeaders,
384
+ typesComment,
385
+ withCredentials,
386
+ propsOptional,
387
+ retryTimeoutMs
388
+ )
389
+ )
390
+ toTry.push(downloadAndWriteGraphQL.bind(null, logger, url, folder, name, generateImplementation, typesOnly))
391
+ }
392
+ } else {
393
+ // add readFromFileAndWrite to the functions only if it's not an URL
394
+ toTry.push(
395
+ readFromFileAndWrite.bind(
396
+ null,
397
+ logger,
398
+ url,
399
+ folder,
400
+ name,
401
+ generateImplementation,
402
+ typesOnly,
403
+ fullRequest,
404
+ fullResponse,
405
+ optionalHeaders,
406
+ validateResponse,
407
+ isFrontend,
408
+ language,
409
+ typesComment,
410
+ withCredentials,
411
+ propsOptional
412
+ )
413
+ )
414
+ }
415
+ for (const fn of toTry) {
416
+ found = await fn()
417
+ if (found) {
418
+ break
419
+ }
420
+ }
421
+ /* c8 ignore next 3 */
422
+ if (!found) {
423
+ throw new Error(`Could not find a valid OpenAPI or GraphQL schema at ${url}`)
424
+ }
425
+ }
426
+
427
+ function getPackageJSON ({ name, generateImplementation }) {
428
+ const obj = {
429
+ name,
430
+ types: `./${name}.d.ts`
431
+ }
432
+
433
+ if (generateImplementation) {
434
+ obj.main = `./${name}.js`
435
+ }
436
+
437
+ return JSON.stringify(obj, null, 2)
438
+ }
439
+
440
+ export async function command (argv) {
441
+ const help = helpMe({
442
+ dir: join(import.meta.dirname, 'help'),
443
+ // the default
444
+ ext: '.txt'
445
+ })
446
+ const {
447
+ _: [url],
448
+ ...options
449
+ } = parseArgs(argv, {
450
+ string: ['name', 'folder', 'optional-headers', 'language', 'type', 'url-auth-headers', 'types-comment'],
451
+ boolean: [
452
+ 'typescript',
453
+ 'full-response',
454
+ 'types-only',
455
+ 'full-request',
456
+ 'full',
457
+ 'frontend',
458
+ 'validate-response',
459
+ 'props-optional',
460
+ 'skip-config-update'
461
+ ],
462
+ default: {
463
+ typescript: false,
464
+ language: 'js',
465
+ full: true
466
+ },
467
+ alias: {
468
+ n: 'name',
469
+ f: 'folder',
470
+ t: 'typescript',
471
+ c: 'config',
472
+ F: 'full',
473
+ h: 'help'
474
+ }
475
+ })
476
+
477
+ if (options.full || options.F) {
478
+ // force both fullRequest and fullResponse
479
+ options['full-request'] = true
480
+ options['full-response'] = true
481
+ }
482
+ const stream = pinoPretty({
483
+ translateTime: 'SYS:HH:MM:ss',
484
+ ignore: 'hostname,pid',
485
+ minimumLevel: 'debug',
486
+ sync: true
487
+ })
488
+
489
+ const logger = pino(stream)
490
+
491
+ if (!url || options.help) {
492
+ await help.toStdout()
493
+ process.exit(1)
494
+ }
495
+
496
+ try {
497
+ options.isFrontend = !!options.frontend
498
+ if (options['types-only']) {
499
+ options.generateImplementation = false
500
+ options.typesOnly = true
501
+ } else {
502
+ options.generateImplementation = options.isFrontend ? true : !options.config
503
+ }
504
+
505
+ options.fullRequest = options['full-request']
506
+ options.fullResponse = options['full-response']
507
+ options.propsOptional = options['props-optional'] ?? true
508
+
509
+ options.optionalHeaders = options['optional-headers']
510
+ ? options['optional-headers'].split(',').map(h => h.trim())
511
+ : []
512
+
513
+ options.validateResponse = options['validate-response']
514
+
515
+ if (!options.name) {
516
+ options.name = options.isFrontend ? 'api' : 'client'
517
+ }
518
+ options.folder = options.folder || join(process.cwd(), options.name)
519
+ options.urlAuthHeaders = options['url-auth-headers']
520
+ options.typesComment = options['types-comment']
521
+ options.withCredentials = options['with-credentials']
522
+ options.skipConfigUpdate = options['skip-config-update'] ?? true
523
+ options.retryTimeoutMs = options['retry-timeout-ms']
524
+ await downloadAndProcess({ url, ...options, logger })
525
+ logger.info(`Client generated successfully into ${options.folder}`)
526
+ logger.info('Check out the docs to know more: https://docs.platformatic.dev/docs/service/overview')
527
+ } catch (err) {
528
+ logger.error(err.message)
529
+ process.exit(1)
530
+ }
531
+ }
532
+
533
+ if (import.meta.main) {
534
+ command(process.argv.slice(2))
535
+ }
536
+
537
+ export * as errors from './lib/errors.js'
@@ -0,0 +1,15 @@
1
+ import { expectError, expectType } from 'tsd'
2
+ import { command, errors } from '.'
3
+ import { FastifyError } from '@fastify/error'
4
+
5
+ // command
6
+ expectType<Promise<void>>(command([]))
7
+ expectType<Promise<void>>(command(['foo', 'bar']))
8
+ expectError<Promise<void>>(command(false))
9
+ expectError<Promise<void>>(command([4, 2]))
10
+
11
+ // errors
12
+ expectType<FastifyError>(errors.TypeNotSupportedError('someType'))
13
+ expectType<FastifyError>(errors.UnknownTypeError('otherType'))
14
+ expectError<FastifyError>(errors.TypeNotSupportedError())
15
+ expectError<FastifyError>(errors.UnknownTypeError())
package/lib/errors.js ADDED
@@ -0,0 +1,6 @@
1
+ import createError from '@fastify/error'
2
+
3
+ export const ERROR_PREFIX = 'PLT_MASSIMO_CLI'
4
+
5
+ export const UnknownTypeError = createError(`${ERROR_PREFIX}_UNKNOWN_TYPE`, 'Unknown type %s')
6
+ export const TypeNotSupportedError = createError(`${ERROR_PREFIX}_TYPE_NOT_SUPPORTED`, 'Type %s not supported')