@platformatic/service 1.13.7 → 1.14.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.
@@ -0,0 +1,862 @@
1
+ 'use strict'
2
+
3
+ const { join, relative } = require('node:path')
4
+ const { readFile, writeFile, mkdir, readdir, rm } = require('node:fs/promises')
5
+ const { inspect } = require('node:util')
6
+ const pino = require('pino')
7
+ const pretty = require('pino-pretty')
8
+ const cliProgress = require('cli-progress')
9
+ const { request } = require('undici')
10
+ const { green } = require('colorette')
11
+ const compareOpenApiSchemas = require('openapi-schema-diff')
12
+ const { default: CodeBlockWriter } = require('code-block-writer')
13
+ const { loadConfig } = require('@platformatic/config')
14
+ const { analyze, write: writeConfig } = require('@platformatic/metaconfig')
15
+ const { getOpenapiSchema } = require('./get-openapi-schema.js')
16
+ const { platformaticService } = require('../index.js')
17
+ const {
18
+ isFileAccessible,
19
+ changeOpenapiSchemaPrefix,
20
+ convertOpenApiToFastifyPath,
21
+ convertOpenApiToFastifyRouteSchema
22
+ } = require('./utils.js')
23
+
24
+ const OPENAI_SERVICE_HOST = 'https://openai.platformatic.cloud'
25
+ const OPENAI_WARNING = '// !!! This function was generated by OpenAI. Check before use !!!\n'
26
+
27
+ const HTTP_METHODS_WITH_BODY = ['POST', 'PUT', 'PATCH', 'DELETE']
28
+
29
+ async function generateRequestMapper (openaiProxyHost, userApiKey, content) {
30
+ openaiProxyHost = openaiProxyHost ?? OPENAI_SERVICE_HOST
31
+
32
+ const url = openaiProxyHost + '/openapi/mappers/request'
33
+ const { statusCode, body } = await request(url, {
34
+ method: 'POST',
35
+ headers: {
36
+ 'Content-Type': 'application/json',
37
+ 'x-platformatic-user-api-key': userApiKey
38
+ },
39
+ body: JSON.stringify(content)
40
+ })
41
+
42
+ if (statusCode !== 200) {
43
+ const error = await body.text()
44
+ throw new Error('Failed to generate request mapper: ' + error)
45
+ }
46
+
47
+ const data = await body.json()
48
+ return OPENAI_WARNING + data.code
49
+ }
50
+
51
+ async function generateResponseMapper (openaiProxyHost, userApiKey, content) {
52
+ openaiProxyHost = openaiProxyHost ?? OPENAI_SERVICE_HOST
53
+
54
+ const url = openaiProxyHost + '/openapi/mappers/response'
55
+ const { statusCode, body } = await request(url, {
56
+ method: 'POST',
57
+ headers: {
58
+ 'Content-Type': 'application/json',
59
+ 'x-platformatic-user-api-key': userApiKey
60
+ },
61
+ body: JSON.stringify(content)
62
+ })
63
+
64
+ if (statusCode !== 200) {
65
+ const error = await body.text()
66
+ throw new Error('Failed to generate response mapper: ' + error)
67
+ }
68
+
69
+ const data = await body.json()
70
+ return OPENAI_WARNING + data.code
71
+ }
72
+
73
+ async function generateMapperForDeletedRoute (
74
+ logger,
75
+ method,
76
+ openapiPath,
77
+ prevVersionConfig,
78
+ nextVersionConfig,
79
+ routeDiff
80
+ ) {
81
+ const writer = new CodeBlockWriter({
82
+ newLine: '\n',
83
+ indentNumberOfSpaces: 2,
84
+ useTabs: false,
85
+ useSingleQuote: true
86
+ })
87
+
88
+ const nextVersion = nextVersionConfig.version
89
+
90
+ const fastifySchema = convertOpenApiToFastifyRouteSchema(routeDiff.sourceSchema)
91
+ const serializedFastifySchema = inspect(
92
+ fastifySchema ?? {},
93
+ {
94
+ depth: null,
95
+ compact: false
96
+ }
97
+ )
98
+
99
+ writer.writeLine(
100
+ `// Route ${method.toUpperCase()} "${openapiPath}" was deleted in the "${nextVersion}" API`
101
+ )
102
+
103
+ const fastifyPath = convertOpenApiToFastifyPath(openapiPath)
104
+
105
+ const errorMessage =
106
+ `Route ${method.toUpperCase()} "${openapiPath}" was deleted in the "${nextVersion}" API`
107
+
108
+ writer.write('fastify.route({').indent(() => {
109
+ writer.writeLine(`method: '${method.toUpperCase()}',`)
110
+ writer.writeLine(`url: '${fastifyPath}',`)
111
+ writer.writeLine(`schema: ${serializedFastifySchema},`)
112
+ writer.write('handler: async (req, reply) => ').block(() => {
113
+ writer.writeLine('reply').indent(() => {
114
+ writer.writeLine('.code(404)')
115
+ writer.writeLine('.send({').indent(() => {
116
+ writer.writeLine('code: \'PLT_ERR_DELETED_ROUTE\',')
117
+ writer.writeLine(`message: '${errorMessage}'`)
118
+ }).write('})')
119
+ })
120
+ })
121
+ }).write('})')
122
+
123
+ return writer.toString()
124
+ }
125
+
126
+ function generateDefaultRequestMapper () {
127
+ const writer = new CodeBlockWriter({
128
+ newLine: '\n',
129
+ indentNumberOfSpaces: 2,
130
+ useTabs: false,
131
+ useSingleQuote: true
132
+ })
133
+
134
+ writer.writeLine('function mapInputData (inputData) {').indent(() => {
135
+ writer.writeLine('// Map your input params here')
136
+ writer.blankLine()
137
+ writer.writeLine('delete inputData.headers.connection')
138
+ writer.writeLine('delete inputData.headers[\'content-length\']')
139
+ writer.writeLine('delete inputData.headers[\'transfer-encoding\']')
140
+ writer.blankLine()
141
+ writer.write('return {').indent(() => {
142
+ writer.writeLine('pathParams: inputData.pathParams,')
143
+ writer.writeLine('queryParams: inputData.queryParams,')
144
+ writer.writeLine('headers: inputData.headers,')
145
+ writer.writeLine('requestBody: inputData.requestBody')
146
+ }).write('}')
147
+ }).write('}\n')
148
+
149
+ return writer.toString()
150
+ }
151
+
152
+ function generateDefaultResponseMapper () {
153
+ const writer = new CodeBlockWriter({
154
+ newLine: '\n',
155
+ indentNumberOfSpaces: 2,
156
+ useTabs: false,
157
+ useSingleQuote: true
158
+ })
159
+
160
+ writer.writeLine('function mapOutputData (outputData) {').indent(() => {
161
+ writer.writeLine('// Map your output params here')
162
+ writer.write('return {').indent(() => {
163
+ writer.writeLine('headers: outputData.headers,')
164
+ writer.writeLine('responseBody: outputData.responseBody')
165
+ }).write('}')
166
+ }).write('}\n')
167
+
168
+ return writer.toString()
169
+ }
170
+
171
+ async function generateHandlerForChangedRoute (
172
+ logger,
173
+ method,
174
+ openapiPath,
175
+ prevVersionPrefix,
176
+ nextVersionPrefix,
177
+ routeDiff,
178
+ userApiKey,
179
+ openai,
180
+ openaiProxyHost
181
+ ) {
182
+ const writer = new CodeBlockWriter({
183
+ newLine: '\n',
184
+ indentNumberOfSpaces: 2,
185
+ useTabs: false,
186
+ useSingleQuote: true
187
+ })
188
+
189
+ const requestChanges = routeDiff.changes.filter(
190
+ c => c.type === 'parameter' || c.type === 'requestBody'
191
+ )
192
+ const responseChanges = routeDiff.changes.filter(
193
+ c => c.type === 'responseHeader' || c.type === 'responseBody'
194
+ )
195
+
196
+ const includeRequestBody = HTTP_METHODS_WITH_BODY.includes(method.toUpperCase())
197
+
198
+ let genRequestMapperReq = null
199
+ if (requestChanges.length > 0) {
200
+ if (openai) {
201
+ genRequestMapperReq = generateRequestMapper(
202
+ openaiProxyHost,
203
+ userApiKey,
204
+ {
205
+ includeBody: includeRequestBody,
206
+ sourceSchema: {
207
+ parameters: routeDiff.sourceSchema.parameters ?? [],
208
+ requestBody: routeDiff.sourceSchema.requestBody ?? {}
209
+ },
210
+ targetSchema: {
211
+ parameters: routeDiff.targetSchema.parameters ?? [],
212
+ requestBody: routeDiff.targetSchema.requestBody ?? {}
213
+ }
214
+ }
215
+ ).catch(async (err) => {
216
+ logger.warn({ err }, 'Failed to generate request params mapper. Using default mapper.')
217
+ return generateDefaultRequestMapper()
218
+ })
219
+ } else {
220
+ genRequestMapperReq = generateDefaultRequestMapper()
221
+ }
222
+ }
223
+
224
+ const mappedStatusCodes = []
225
+ const getResponseMappersReqs = []
226
+
227
+ const sourceSchemaResponses = routeDiff.sourceSchema.responses ?? {}
228
+ const targetSchemaResponses = routeDiff.targetSchema.responses ?? {}
229
+
230
+ for (const statusCode in targetSchemaResponses) {
231
+ const targetSchemaResponse = targetSchemaResponses[statusCode]
232
+ const sourceSchemaResponse = sourceSchemaResponses[statusCode]
233
+
234
+ const genMapperReqBody = { sourceSchema: {}, targetSchema: {} }
235
+
236
+ const headerChanges = responseChanges.filter(change =>
237
+ change.type === 'responseHeader' &&
238
+ change.statusCode === statusCode
239
+ )
240
+
241
+ const bodyChange = responseChanges.find(change =>
242
+ change.action === 'changed' &&
243
+ change.type === 'responseBody' &&
244
+ change.statusCode === statusCode &&
245
+ change.mediaType === 'application/json'
246
+ )
247
+
248
+ if (bodyChange || headerChanges.length > 0) {
249
+ if (headerChanges.length > 0) {
250
+ genMapperReqBody.targetSchema.headers = sourceSchemaResponse.headers
251
+ genMapperReqBody.sourceSchema.headers = targetSchemaResponse.headers
252
+ }
253
+ if (bodyChange) {
254
+ genMapperReqBody.targetSchema.responseBody = bodyChange.sourceSchema.schema
255
+ genMapperReqBody.sourceSchema.responseBody = bodyChange.targetSchema.schema
256
+ }
257
+
258
+ let genResponseMapperReq = null
259
+ if (openai) {
260
+ genResponseMapperReq = generateResponseMapper(
261
+ openaiProxyHost,
262
+ userApiKey,
263
+ genMapperReqBody
264
+ ).catch(async (err) => {
265
+ logger.warn({ err }, 'Failed to generate response params mapper. Using default mapper.')
266
+ return generateDefaultResponseMapper()
267
+ })
268
+ } else {
269
+ genResponseMapperReq = generateDefaultResponseMapper()
270
+ }
271
+
272
+ mappedStatusCodes.push(statusCode)
273
+ getResponseMappersReqs.push(genResponseMapperReq)
274
+ }
275
+ }
276
+
277
+ const [requestMapper, ...responseMappers] = await Promise.all([
278
+ genRequestMapperReq,
279
+ ...getResponseMappersReqs
280
+ ])
281
+
282
+ writer.write('async (req, reply) => ').block(() => {
283
+ if (requestMapper) {
284
+ writer.writeLine(requestMapper)
285
+ }
286
+
287
+ for (let i = 0; i < mappedStatusCodes.length; i++) {
288
+ const statusCode = mappedStatusCodes[i]
289
+ const responseMapper = responseMappers[i]
290
+
291
+ const renamedResponseMapper = responseMapper.replace(
292
+ 'mapOutputData',
293
+ `mapOutputData${statusCode}`
294
+ )
295
+
296
+ writer.writeLine(renamedResponseMapper)
297
+ }
298
+
299
+ if (requestMapper) {
300
+ writer.write('const inputParams = mapInputData({').indent(() => {
301
+ writer.writeLine('pathParams: req.params,')
302
+ writer.writeLine('queryParams: req.query,')
303
+ writer.writeLine('headers: req.headers,')
304
+ if (includeRequestBody) {
305
+ writer.writeLine('requestBody: req.body')
306
+ }
307
+ }).write('})')
308
+ } else {
309
+ writer.write('const inputParams = {').indent(() => {
310
+ writer.writeLine('pathParams: req.params,')
311
+ writer.writeLine('queryParams: req.query,')
312
+ writer.writeLine('headers: req.headers,')
313
+ if (includeRequestBody) {
314
+ writer.writeLine('requestBody: req.body')
315
+ }
316
+ }
317
+ ).write('}')
318
+ }
319
+ writer.blankLine()
320
+
321
+ let nextRouteUrl = null
322
+ const pathParams = openapiPath.match(/{(\w+)}/g) ?? []
323
+ if (pathParams.length > 0) {
324
+ let mappedPath = openapiPath
325
+ for (const pathParam of pathParams) {
326
+ let paramName = pathParam.slice(1, -1)
327
+ paramName = paramName === 'wildcard' ? '*' : paramName
328
+ mappedPath = mappedPath.replace(pathParam, '${params[\'' + paramName + '\']}')
329
+ }
330
+
331
+ writer.writeLine('const params = inputParams.pathParams')
332
+ nextRouteUrl = `\`${nextVersionPrefix + mappedPath}\``
333
+ } else {
334
+ nextRouteUrl = `'${nextVersionPrefix + openapiPath}'`
335
+ }
336
+
337
+ writer.write('const res = await fastify.inject({').indent(() => {
338
+ writer.writeLine('method: \'' + method.toUpperCase() + '\',')
339
+ writer.writeLine(`url: ${nextRouteUrl},`)
340
+ writer.writeLine('query: inputParams.queryParams,')
341
+ writer.writeLine('headers: inputParams.headers,')
342
+ if (includeRequestBody) {
343
+ writer.writeLine('payload: inputParams.requestBody')
344
+ }
345
+ }).write('})')
346
+ writer.blankLine()
347
+
348
+ writer.write('let outputParams = {').indent(() => {
349
+ writer.writeLine('headers: res.headers,')
350
+ writer.writeLine('responseBody: res.body')
351
+ }).write('}')
352
+ writer.blankLine()
353
+
354
+ for (let i = 0; i < mappedStatusCodes.length; i++) {
355
+ const statusCode = mappedStatusCodes[i]
356
+ const mapperFunctionName = `mapOutputData${statusCode}`
357
+
358
+ writer.write(`if (res.statusCode === ${statusCode}) `).block(() => {
359
+ writer.writeLine('const responseBody = outputParams.responseBody')
360
+ writer.write('if (typeof responseBody === \'string\')').block(() => {
361
+ writer.writeLine('outputParams.responseBody = JSON.parse(responseBody)')
362
+ })
363
+ writer.write(`outputParams = ${mapperFunctionName}(outputParams)`)
364
+ })
365
+ writer.blankLine()
366
+ }
367
+
368
+ writer.write('reply').indent(() => {
369
+ writer.writeLine('.code(res.statusCode)')
370
+ writer.writeLine('.headers(outputParams.headers)')
371
+ })
372
+ writer.blankLine()
373
+ writer.writeLine('return outputParams.responseBody')
374
+ })
375
+
376
+ return writer.toString()
377
+ }
378
+
379
+ async function generateMapperForChangedRoute (
380
+ logger,
381
+ method,
382
+ openapiPath,
383
+ prevVersionConfig,
384
+ nextVersionConfig,
385
+ routeDiff,
386
+ userApiKey,
387
+ openai,
388
+ openaiProxyHost
389
+ ) {
390
+ const writer = new CodeBlockWriter({
391
+ newLine: '\n',
392
+ indentNumberOfSpaces: 2,
393
+ useTabs: false,
394
+ useSingleQuote: true
395
+ })
396
+
397
+ const nextVersion = nextVersionConfig.version
398
+
399
+ const prevVersionPrefix = prevVersionConfig.openapi.prefix ?? ''
400
+ const nextVersionPrefix = nextVersionConfig.openapi.prefix ?? ''
401
+
402
+ writer.writeLine(
403
+ `/* Route ${method.toUpperCase()} "${openapiPath}" was changed in the "${nextVersion}" API`
404
+ )
405
+ writer.indent(() => {
406
+ for (const componentChange of routeDiff.changes) {
407
+ writer.writeLine(`- ${componentChange.comment}`)
408
+ if (componentChange.action !== 'changed') continue
409
+
410
+ writer.indent(() => {
411
+ for (const keywordChange of componentChange.changes ?? []) {
412
+ writer.writeLine(`- ${keywordChange.comment}`)
413
+ if (keywordChange.keyword !== 'schema') continue
414
+
415
+ writer.indent(() => {
416
+ for (const schemaChange of keywordChange.changes ?? []) {
417
+ const prevSchemaComments = JSON.stringify(schemaChange.source || '', null, 2)
418
+ const nextSchemaComments = JSON.stringify(schemaChange.target || '', null, 2)
419
+
420
+ writer.writeLine(`- schema diff at "${schemaChange.jsonPath}":`)
421
+ writer.indent(() => {
422
+ prevSchemaComments.split('\n').forEach(l => writer.writeLine('- ' + l))
423
+ nextSchemaComments.split('\n').forEach(l => writer.writeLine('+ ' + l))
424
+ writer.blankLine()
425
+ })
426
+ }
427
+ })
428
+ }
429
+ })
430
+ }
431
+ })
432
+
433
+ writer.writeLine('*/')
434
+
435
+ const fastifySchema = convertOpenApiToFastifyRouteSchema(routeDiff.sourceSchema)
436
+ const serializedFastifySchema = inspect(
437
+ fastifySchema ?? {},
438
+ {
439
+ depth: null,
440
+ compact: false
441
+ }
442
+ )
443
+
444
+ const routeHandler = await generateHandlerForChangedRoute(
445
+ logger,
446
+ method,
447
+ openapiPath,
448
+ prevVersionPrefix,
449
+ nextVersionPrefix,
450
+ routeDiff,
451
+ userApiKey,
452
+ openai,
453
+ openaiProxyHost
454
+ )
455
+
456
+ const fastifyPath = convertOpenApiToFastifyPath(openapiPath)
457
+
458
+ writer.write('fastify.route({').indent(() => {
459
+ writer.writeLine(`method: '${method.toUpperCase()}',`)
460
+ writer.writeLine(`url: '${fastifyPath}',`)
461
+ writer.writeLine(`schema: ${serializedFastifySchema},`)
462
+ writer.write(`handler: ${routeHandler}`)
463
+ }).write('})')
464
+
465
+ return writer.toString()
466
+ }
467
+
468
+ async function generateMapperPluginForDeletedRoute (
469
+ logger,
470
+ deletedRouteDiff,
471
+ prevVersionConfig,
472
+ nextVersionConfig
473
+ ) {
474
+ const writer = new CodeBlockWriter({
475
+ newLine: '\n',
476
+ indentNumberOfSpaces: 2,
477
+ useTabs: false,
478
+ useSingleQuote: true
479
+ })
480
+
481
+ writer.writeLine('\'use strict\'')
482
+ writer.blankLine()
483
+
484
+ const method = deletedRouteDiff.method
485
+ const openapiPath = deletedRouteDiff.path
486
+
487
+ const routeMapper = await generateMapperForDeletedRoute(
488
+ logger,
489
+ method,
490
+ openapiPath,
491
+ prevVersionConfig,
492
+ nextVersionConfig,
493
+ deletedRouteDiff
494
+ )
495
+
496
+ writer.write('module.exports = async function (fastify, opts) {').indent(() => {
497
+ writer.write(routeMapper)
498
+ }).write('}\n')
499
+ return writer.toString()
500
+ }
501
+
502
+ async function generateMapperPluginForChangesRoute (
503
+ logger,
504
+ changedRouteDiff,
505
+ prevVersionConfig,
506
+ nextVersionConfig,
507
+ userApiKey,
508
+ openai,
509
+ openaiProxyHost
510
+ ) {
511
+ const writer = new CodeBlockWriter({
512
+ newLine: '\n',
513
+ indentNumberOfSpaces: 2,
514
+ useTabs: false,
515
+ useSingleQuote: true
516
+ })
517
+
518
+ writer.writeLine('\'use strict\'')
519
+ writer.blankLine()
520
+
521
+ const method = changedRouteDiff.method
522
+ const openapiPath = changedRouteDiff.path
523
+
524
+ const routeMapper = await generateMapperForChangedRoute(
525
+ logger,
526
+ method,
527
+ openapiPath,
528
+ prevVersionConfig,
529
+ nextVersionConfig,
530
+ changedRouteDiff,
531
+ userApiKey,
532
+ openai,
533
+ openaiProxyHost
534
+ )
535
+
536
+ writer.write('module.exports = async function (fastify, opts) {').indent(() => {
537
+ writer.write(routeMapper)
538
+ }).write('}\n')
539
+ return writer.toString()
540
+ }
541
+
542
+ function createProgressBar (count) {
543
+ const multibar = new cliProgress.MultiBar({
544
+ format: 'Routes generation: |' + green('{bar}') + '| {value}/{total} Generated routes | Time: {duration}s',
545
+ clearOnComplete: true,
546
+ stopOnComplete: true,
547
+ gracefulExit: true
548
+ }, cliProgress.Presets.shades_classic)
549
+
550
+ const progressBar = multibar.create(count, 0)
551
+ return {
552
+ increment: () => progressBar.increment(),
553
+ stop: () => {
554
+ progressBar.stop()
555
+ multibar.stop()
556
+ }
557
+ }
558
+ }
559
+
560
+ function generatePluginName (method, openapiPath) {
561
+ const methodPrefix = method.toLowerCase()
562
+ const pathPrefix = openapiPath
563
+ .split('-')
564
+ .map(p => p.replace(/\//g, '-'))
565
+ .join('--')
566
+
567
+ return methodPrefix + pathPrefix + '.js'
568
+ }
569
+
570
+ function generateOpenapiPath (pluginName) {
571
+ const index = pluginName.indexOf('-')
572
+ const methodPrefix = pluginName.slice(0, index)
573
+ const pathPrefix = pluginName.slice(index + 1, -3)
574
+
575
+ const method = methodPrefix.toLowerCase()
576
+
577
+ // Should map "-" to "/" only if there is no another "-" next to it or before it
578
+ const path = '/' + pathPrefix
579
+ .split('--')
580
+ .map(p => p.replace(/-/g, '/'))
581
+ .join('-')
582
+
583
+ return { method, path }
584
+ }
585
+
586
+ async function createMappersPlugins ({
587
+ logger,
588
+ configManager,
589
+ prevVersion,
590
+ nextVersion,
591
+ prevOpenapiSchema,
592
+ nextOpenapiSchema,
593
+ userApiKey = null,
594
+ openai = false,
595
+ openaiProxyHost = OPENAI_SERVICE_HOST
596
+ }) {
597
+ const config = configManager.current
598
+
599
+ const versions = config.versions ?? {}
600
+ const versionsConfigs = versions.configs ?? []
601
+
602
+ const prevVersionConfig = versionsConfigs.find(c => c.version === prevVersion)
603
+ const nextVersionConfig = versionsConfigs.find(c => c.version === nextVersion)
604
+
605
+ const prevNormalizedOpenapiSchema = changeOpenapiSchemaPrefix(
606
+ prevOpenapiSchema,
607
+ prevVersionConfig.openapi.prefix,
608
+ ''
609
+ )
610
+
611
+ const nextNormalizedOpenapiSchema = changeOpenapiSchemaPrefix(
612
+ nextOpenapiSchema,
613
+ nextVersionConfig.openapi.prefix,
614
+ ''
615
+ )
616
+
617
+ const schemasDiff = compareOpenApiSchemas(
618
+ prevNormalizedOpenapiSchema,
619
+ nextNormalizedOpenapiSchema
620
+ )
621
+
622
+ const modifiedRoutesCount =
623
+ schemasDiff.changedRoutes.length +
624
+ schemasDiff.deletedRoutes.length
625
+
626
+ if (modifiedRoutesCount === 0) {
627
+ logger.info(
628
+ `No changes found between "${prevVersion}" and "${nextVersion}" openapi schemas.` +
629
+ ' Skipping mappers generation'
630
+ )
631
+ return null
632
+ }
633
+
634
+ logger.info(`Generating openapi mappers for "${prevVersion}" -> "${nextVersion}"`)
635
+
636
+ const progressBar = createProgressBar(modifiedRoutesCount)
637
+ const onRouteGenerated = async () => {
638
+ progressBar.increment()
639
+ }
640
+
641
+ const mappersDir = join(versions.dir, prevVersionConfig.version, 'mappers')
642
+ const mappersDirExists = await isFileAccessible(mappersDir)
643
+ if (!mappersDirExists) {
644
+ logger.info(`Creating mappers directory for "${prevVersion}" -> "${nextVersion}"`)
645
+ await mkdir(mappersDir, { recursive: true })
646
+ }
647
+
648
+ const genPluginsReqs = []
649
+ for (const deletedRouteDiff of schemasDiff.deletedRoutes) {
650
+ const method = deletedRouteDiff.method
651
+ const openapiPath = deletedRouteDiff.path
652
+
653
+ const mapperPluginFileName = generatePluginName(method, openapiPath)
654
+ const mapperPluginFilePath = join(mappersDir, mapperPluginFileName)
655
+
656
+ const generateMapperReq = generateMapperPluginForDeletedRoute(
657
+ logger,
658
+ deletedRouteDiff,
659
+ prevVersionConfig,
660
+ nextVersionConfig
661
+ )
662
+ .then(
663
+ async (mapperPlugin) => {
664
+ await writeFile(mapperPluginFilePath, mapperPlugin, 'utf8')
665
+ }
666
+ )
667
+ .then(
668
+ async () => {
669
+ await onRouteGenerated(method, openapiPath)
670
+ }
671
+ )
672
+
673
+ genPluginsReqs.push(generateMapperReq)
674
+ }
675
+
676
+ for (const changedRouteDiff of schemasDiff.changedRoutes) {
677
+ const method = changedRouteDiff.method
678
+ const openapiPath = changedRouteDiff.path
679
+
680
+ const mapperPluginFileName = generatePluginName(method, openapiPath)
681
+ const mapperPluginFilePath = join(mappersDir, mapperPluginFileName)
682
+
683
+ const generateMapperReq = generateMapperPluginForChangesRoute(
684
+ logger,
685
+ changedRouteDiff,
686
+ prevVersionConfig,
687
+ nextVersionConfig,
688
+ userApiKey,
689
+ openai,
690
+ openaiProxyHost
691
+ )
692
+ .then(
693
+ async (mapperPlugin) => {
694
+ await writeFile(mapperPluginFilePath, mapperPlugin, 'utf8')
695
+ }
696
+ )
697
+ .then(
698
+ async () => {
699
+ await onRouteGenerated(method, openapiPath)
700
+ }
701
+ )
702
+
703
+ genPluginsReqs.push(generateMapperReq)
704
+ }
705
+
706
+ await Promise.all(genPluginsReqs)
707
+ progressBar.stop()
708
+
709
+ const mappersPluginsNames = await readdir(mappersDir)
710
+ for (const mappersPluginName of mappersPluginsNames) {
711
+ const { method, path } = generateOpenapiPath(mappersPluginName)
712
+
713
+ const isDeletedRoute = schemasDiff.deletedRoutes.some(
714
+ routeDiff => {
715
+ return routeDiff.method === method && routeDiff.path === path
716
+ }
717
+ )
718
+ const isChangedRoute = schemasDiff.changedRoutes.some(
719
+ routeDiff => {
720
+ return routeDiff.method === method && routeDiff.path === path
721
+ }
722
+ )
723
+ if (!isDeletedRoute && !isChangedRoute) {
724
+ logger.info(`Removing obsolete mappers plugin "${mappersPluginName}"`)
725
+ const mapperPluginPath = join(mappersDir, mappersPluginName)
726
+ await rm(mapperPluginPath, { force: true })
727
+ }
728
+ }
729
+
730
+ logger.info(`Updating "${prevVersion}" version config with mappers plugin path`)
731
+ await addMappersToConfig(mappersDir, prevVersionConfig.version, configManager)
732
+ }
733
+
734
+ async function addMappersToConfig (mappersDir, version, configManager) {
735
+ const config = configManager.current
736
+ const prevVersionConfig = config.versions.configs.find(c => c.version === version)
737
+
738
+ const pluginsPaths = prevVersionConfig.plugins?.paths ?? []
739
+ for (let foundPluginPath of pluginsPaths) {
740
+ foundPluginPath = typeof foundPluginPath === 'string'
741
+ ? foundPluginPath
742
+ : foundPluginPath.path
743
+
744
+ if (mappersDir.startsWith(foundPluginPath)) return
745
+ }
746
+
747
+ const metaConfig = await analyze({ file: configManager.fullPath })
748
+ const rawConfig = metaConfig.config
749
+ const rawPrevVersionConfig = rawConfig.versions.configs.find(c => c.version === version)
750
+
751
+ const relativePath = relative(configManager.dirname, mappersDir)
752
+ if (!prevVersionConfig.plugins) {
753
+ prevVersionConfig.plugins = { paths: [] }
754
+ rawPrevVersionConfig.plugins = { paths: [] }
755
+ }
756
+ prevVersionConfig.plugins.paths.push(relativePath)
757
+ rawPrevVersionConfig.plugins.paths.push(relativePath)
758
+ await Promise.all([configManager.update(), writeConfig(metaConfig)])
759
+ }
760
+
761
+ async function execute ({
762
+ logger,
763
+ configManager,
764
+ userApiKey,
765
+ openai,
766
+ openaiProxyHost
767
+ }) {
768
+ const config = configManager.current
769
+
770
+ const versions = config.versions ?? {}
771
+ const versionsConfigs = versions.configs ?? []
772
+
773
+ const nextVersionConfig = versionsConfigs.at(-1)
774
+ if (!nextVersionConfig) {
775
+ logger.info('No versions found. Skipping version update.')
776
+ return
777
+ }
778
+
779
+ const nextVersion = nextVersionConfig.version
780
+ const nextOpenapiSchemaPath = nextVersionConfig.openapi.path
781
+
782
+ logger.info(`Loading openapi schema for "${nextVersion}"`)
783
+ const nextOpenapiSchema = await getOpenapiSchema({
784
+ logger,
785
+ configManager,
786
+ version: nextVersionConfig.version
787
+ })
788
+ logger.info(`Updating openapi schema for "${nextVersion}"`)
789
+ await writeFile(nextOpenapiSchemaPath, JSON.stringify(nextOpenapiSchema, null, 2), 'utf8')
790
+
791
+ const prevVersionConfig = versionsConfigs.at(-2)
792
+ if (!prevVersionConfig) {
793
+ logger.info('No previous versions found. Skipping mappers generation.')
794
+ return
795
+ }
796
+
797
+ const prevVersion = prevVersionConfig.version
798
+ const prevOpenapiSchemaPath = prevVersionConfig.openapi.path
799
+
800
+ logger.info(`Reading openapi schema for "${prevVersion}"`)
801
+ const prevOpenapiSchemaFile = await readFile(prevOpenapiSchemaPath, 'utf8')
802
+ const prevOpenapiSchema = JSON.parse(prevOpenapiSchemaFile)
803
+
804
+ await createMappersPlugins({
805
+ logger,
806
+ configManager,
807
+ prevVersion,
808
+ nextVersion,
809
+ prevOpenapiSchema,
810
+ nextOpenapiSchema,
811
+ userApiKey,
812
+ openai,
813
+ openaiProxyHost
814
+ })
815
+ }
816
+
817
+ async function updateVersion (_args) {
818
+ const logger = pino(pretty({
819
+ translateTime: 'SYS:HH:MM:ss',
820
+ ignore: 'hostname,pid'
821
+ }))
822
+
823
+ try {
824
+ const { configManager, args } = await loadConfig({
825
+ string: ['openai-proxy-host', 'user-api-key'],
826
+ boolean: ['openai']
827
+ }, _args, platformaticService)
828
+ await configManager.parseAndValidate()
829
+
830
+ const openai = args.openai ?? false
831
+ const openaiProxyHost = args['openai-proxy-host'] ?? OPENAI_SERVICE_HOST
832
+
833
+ let userApiKey = args['user-api-key'] ?? null
834
+ /* c8 ignore next 10 */
835
+ if (!userApiKey && openai) {
836
+ logger.info('Reading platformatic user api key')
837
+ const { getUserApiKey } = await import('@platformatic/authenticate')
838
+ try {
839
+ userApiKey = await getUserApiKey()
840
+ } catch (err) {
841
+ logger.error('Failed to read user api key. Please run "plt login" command.')
842
+ return
843
+ }
844
+ }
845
+
846
+ await execute({
847
+ logger,
848
+ configManager,
849
+ userApiKey,
850
+ openai,
851
+ openaiProxyHost
852
+ })
853
+
854
+ // TODO: find out why process stucks sometimes
855
+ process.exit(0)
856
+ } catch (err) {
857
+ logger.error(err.message)
858
+ process.exit(1)
859
+ }
860
+ }
861
+
862
+ module.exports = { execute, updateVersion, createMappersPlugins }