@stackql/provider-utils 0.2.3 → 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.
@@ -0,0 +1,492 @@
1
+ // @stackql/provider-utils/src/providerdev/split.js
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import yaml from 'js-yaml';
6
+ import logger from '../logger.js';
7
+ import {
8
+ camelToSnake,
9
+ createDestDir
10
+ } from '../utils.js';
11
+
12
+ // Constants
13
+ const OPERATIONS = ["get", "post", "put", "delete", "patch", "options", "head", "trace"];
14
+ const NON_OPERATIONS = ["parameters", "servers", "summary", "description"];
15
+ const COMPONENTS_CHILDREN = ["schemas", "responses", "parameters", "examples", "requestBodies", "headers", "securitySchemes", "links", "callbacks"];
16
+
17
+ /**
18
+ * Check if operation should be excluded
19
+ * @param {string[]} exclude - List of exclusion criteria
20
+ * @param {Object} opItem - Operation item from OpenAPI doc
21
+ * @param {string} svcDiscriminator - Service discriminator
22
+ * @returns {boolean} - Whether operation should be excluded
23
+ */
24
+ function isOperationExcluded(exclude, opItem) {
25
+ if (!exclude || exclude.length === 0) {
26
+ return false;
27
+ }
28
+
29
+ // Example: exclude based on tags or other criteria
30
+ if (opItem.tags && opItem.tags.some(tag => exclude.includes(tag))) {
31
+ return true;
32
+ }
33
+
34
+ return false;
35
+ }
36
+
37
+ /**
38
+ * Determine service name and description using discriminator
39
+ * @param {string} providerName - Provider name
40
+ * @param {Object} opItem - Operation item
41
+ * @param {string} pathKey - Path key
42
+ * @param {string} svcDiscriminator - Service discriminator
43
+ * @param {Object[]} allTags - All tags from API doc
44
+ * @param {boolean} debug - Debug flag
45
+ * @returns {[string, string]} - [service name, service description]
46
+ */
47
+ function retServiceNameAndDesc(providerName, opItem, pathKey, svcDiscriminator, allTags, debug) {
48
+ let service = "default";
49
+ let serviceDesc = `${providerName} API`;
50
+
51
+ // Use tags if discriminator is "tag"
52
+ if (svcDiscriminator === "tag" && opItem.tags && opItem.tags.length > 0) {
53
+ service = opItem.tags[0].toLowerCase().replace(/-/g, '_').replace(/ /g, '_');
54
+
55
+ // Find description in all_tags
56
+ for (const tag of allTags) {
57
+ if (tag.name === service) {
58
+ serviceDesc = tag.description || serviceDesc;
59
+ break;
60
+ }
61
+ }
62
+ }
63
+
64
+ // Use first significant path segment if discriminator is "path"
65
+ else if (svcDiscriminator === "path") {
66
+ const pathParts = pathKey.replace(/^\//, '').split('/');
67
+ if (pathParts.length > 0) {
68
+ // Find the first path segment that is not 'api' or 'v{number}'
69
+ for (const part of pathParts) {
70
+ const lowerPart = part.toLowerCase();
71
+ // Skip if it's 'api' or matches version pattern 'v1', 'v2', etc.
72
+ if (lowerPart === 'api' || /^v\d+$/.test(lowerPart)) {
73
+ continue;
74
+ }
75
+ service = lowerPart.replace(/-/g, '_').replace(/ /g, '_').replace(/\./g, '_');
76
+ break;
77
+ }
78
+ serviceDesc = `${providerName} ${service} API`;
79
+ }
80
+ }
81
+
82
+ // Check if service should be skipped
83
+ if (service === "skip") {
84
+ return ["skip", ""];
85
+ }
86
+
87
+ return [service, serviceDesc];
88
+ }
89
+
90
+ /**
91
+ * Initialize service map
92
+ * @param {Object} services - Services map
93
+ * @param {string[]} componentsChildren - Components children
94
+ * @param {string} service - Service name
95
+ * @param {string} serviceDesc - Service description
96
+ * @param {Object} apiDoc - API doc
97
+ * @returns {Object} - Updated services map
98
+ */
99
+ function initService(services, componentsChildren, service, serviceDesc, apiDoc) {
100
+ services[service] = {
101
+ openapi: apiDoc.openapi || "3.0.0",
102
+ info: {
103
+ title: `${service} API`,
104
+ description: serviceDesc,
105
+ version: apiDoc.info?.version || "1.0.0"
106
+ },
107
+ paths: {},
108
+ components: {}
109
+ };
110
+
111
+ // Initialize components sections
112
+ services[service].components = {};
113
+ for (const child of componentsChildren) {
114
+ services[service].components[child] = {};
115
+ }
116
+
117
+ // Copy servers if present
118
+ if (apiDoc.servers) {
119
+ services[service].servers = apiDoc.servers;
120
+ }
121
+
122
+ return services;
123
+ }
124
+
125
+ /**
126
+ * Extract all $ref values from an object recursively
127
+ * @param {any} obj - Object to extract refs from
128
+ * @returns {Set<string>} - Set of refs
129
+ */
130
+ function getAllRefs(obj) {
131
+ const refs = new Set();
132
+
133
+ if (typeof obj === 'object' && obj !== null) {
134
+ if (Array.isArray(obj)) {
135
+ for (const item of obj) {
136
+ for (const ref of getAllRefs(item)) {
137
+ refs.add(ref);
138
+ }
139
+ }
140
+ } else {
141
+ for (const [key, value] of Object.entries(obj)) {
142
+ if (key === "$ref" && typeof value === 'string') {
143
+ refs.add(value);
144
+ } else if (typeof value === 'object' && value !== null) {
145
+ for (const ref of getAllRefs(value)) {
146
+ refs.add(ref);
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ return refs;
154
+ }
155
+
156
+ /**
157
+ * Add referenced components to service
158
+ * @param {Set<string>} refs - Set of refs
159
+ * @param {Object} service - Service object
160
+ * @param {Object} components - Components from API doc
161
+ * @param {boolean} debug - Debug flag
162
+ */
163
+ function addRefsToComponents(refs, service, components, debug) {
164
+ for (const ref of refs) {
165
+ const parts = ref.split('/');
166
+
167
+ // Only process refs that point to components
168
+ if (parts.length >= 4 && parts[1] === "components") {
169
+ const componentType = parts[2];
170
+ const componentName = parts[3];
171
+
172
+ // Check if component type exists in service
173
+ if (!service.components[componentType]) {
174
+ service.components[componentType] = {};
175
+ }
176
+
177
+ // Skip if component already added
178
+ if (service.components[componentType][componentName]) {
179
+ continue;
180
+ }
181
+
182
+ // Add component if it exists in source document
183
+ if (components[componentType] && components[componentType][componentName]) {
184
+ service.components[componentType][componentName] = components[componentType][componentName];
185
+ if (debug) {
186
+ logger.debug(`Added component ${componentType}/${componentName}`);
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Add missing type: object to schema objects
195
+ * @param {any} obj - Object to process
196
+ * @returns {any} - Processed object
197
+ */
198
+ function addMissingObjectTypes(obj) {
199
+ if (typeof obj !== 'object' || obj === null) {
200
+ return obj;
201
+ }
202
+
203
+ if (Array.isArray(obj)) {
204
+ return obj.map(item => addMissingObjectTypes(item));
205
+ }
206
+
207
+ // If it has properties but no type, add type: object
208
+ if (obj.properties && !obj.type) {
209
+ obj.type = "object";
210
+ }
211
+
212
+ // Process nested objects
213
+ for (const [key, value] of Object.entries(obj)) {
214
+ if (typeof value === 'object' && value !== null) {
215
+ obj[key] = addMissingObjectTypes(value);
216
+ }
217
+ }
218
+
219
+ return obj;
220
+ }
221
+
222
+ /**
223
+ * Split OpenAPI document into service-specific files
224
+ * @param {Object} options - Options for splitting
225
+ * @returns {Promise<boolean>} - Success status
226
+ */
227
+ export async function split(options) {
228
+ const {
229
+ apiDoc,
230
+ providerName,
231
+ outputDir,
232
+ svcDiscriminator = "tag",
233
+ exclude = null,
234
+ overwrite = true,
235
+ verbose = false
236
+ } = options;
237
+
238
+ // Setup logging based on verbosity
239
+ if (verbose) {
240
+ logger.level = 'debug';
241
+ }
242
+
243
+ logger.info(`📄 Splitting OpenAPI doc for ${providerName}`);
244
+ logger.info(`API Doc: ${apiDoc}`);
245
+ logger.info(`Output: ${outputDir}`);
246
+ logger.info(`Service Discriminator: ${svcDiscriminator}`);
247
+
248
+ // Process exclude list
249
+ const excludeList = exclude ? exclude.split(",") : [];
250
+
251
+ // Read the OpenAPI document
252
+ let apiDocObj;
253
+ try {
254
+ const apiDocContent = fs.readFileSync(apiDoc, 'utf8');
255
+ apiDocObj = yaml.load(apiDocContent);
256
+ } catch (e) {
257
+ logger.error(`❌ Failed to parse ${apiDoc}: ${e.message}`);
258
+ return false;
259
+ }
260
+
261
+ // Create destination directory
262
+ if (!createDestDir(outputDir, overwrite)) {
263
+ return false;
264
+ }
265
+
266
+ // Get API paths
267
+ const apiPaths = apiDocObj.paths || {};
268
+ logger.info(`🔑 Iterating over ${Object.keys(apiPaths).length} paths`);
269
+
270
+ const services = {};
271
+ let opCounter = 0;
272
+
273
+ // Process each path
274
+ for (const [pathKey, pathItem] of Object.entries(apiPaths)) {
275
+ if (verbose) {
276
+ logger.debug(`Processing path ${pathKey}`);
277
+ }
278
+
279
+ if (!pathItem) {
280
+ continue;
281
+ }
282
+
283
+ // Process each operation (HTTP verb)
284
+ for (const [verbKey, opItem] of Object.entries(pathItem)) {
285
+ if (!OPERATIONS.includes(verbKey) || !opItem) {
286
+ continue;
287
+ }
288
+
289
+ opCounter += 1;
290
+ if (opCounter % 100 === 0) {
291
+ logger.info(`⚙️ Operations processed: ${opCounter}`);
292
+ }
293
+
294
+ if (verbose) {
295
+ logger.debug(`Processing operation ${pathKey}:${verbKey}`);
296
+ }
297
+
298
+ // Skip excluded operations
299
+ if (isOperationExcluded(excludeList, opItem, svcDiscriminator)) {
300
+ continue;
301
+ }
302
+
303
+ // Determine service name
304
+ const [service, serviceDesc] = retServiceNameAndDesc(
305
+ providerName, opItem, pathKey, svcDiscriminator,
306
+ apiDocObj.tags || [], verbose
307
+ );
308
+
309
+ // Skip if service is marked to skip
310
+ if (service === 'skip') {
311
+ logger.warn(`⭐️ Skipping service: ${service}`);
312
+ continue;
313
+ }
314
+
315
+ if (verbose) {
316
+ logger.debug(`Service name: ${service}`);
317
+ logger.debug(`Service desc: ${serviceDesc}`);
318
+ }
319
+
320
+ // Initialize service if first occurrence
321
+ if (!services[service]) {
322
+ if (verbose) {
323
+ logger.debug(`First occurrence of ${service}`);
324
+ }
325
+ initService(services, COMPONENTS_CHILDREN, service, serviceDesc, apiDocObj);
326
+ }
327
+
328
+ // Add operation to service
329
+ if (!services[service].paths[pathKey]) {
330
+ if (verbose) {
331
+ logger.debug(`First occurrence of ${pathKey}`);
332
+ }
333
+ services[service].paths[pathKey] = {};
334
+ }
335
+
336
+ services[service].paths[pathKey][verbKey] = opItem;
337
+
338
+ // Special case for GitHub
339
+ if (providerName === 'github' &&
340
+ opItem['x-github'] &&
341
+ opItem['x-github'].subcategory) {
342
+ services[service].paths[pathKey][verbKey]['x-stackQL-resource'] = camelToSnake(
343
+ opItem['x-github'].subcategory
344
+ );
345
+ }
346
+
347
+ // Get all refs for operation
348
+ const opRefs = getAllRefs(opItem);
349
+
350
+ if (verbose) {
351
+ logger.debug(`Found ${opRefs.size} refs for ${service}`);
352
+ }
353
+
354
+ // Add refs to components
355
+ addRefsToComponents(opRefs, services[service], apiDocObj.components || {}, verbose);
356
+
357
+ // Get internal refs
358
+ for (let i = 0; i < 3; i++) { // Internal ref depth
359
+ const intRefs = getAllRefs(services[service].components);
360
+ if (verbose) {
361
+ logger.debug(`Found ${intRefs.size} INTERNAL refs for service ${service}`);
362
+ }
363
+ addRefsToComponents(intRefs, services[service], apiDocObj.components || {}, verbose);
364
+ }
365
+
366
+ // Get deeply nested schema refs
367
+ for (let i = 0; i < 10; i++) { // Schema max ref depth
368
+ const intRefs = getAllRefs(services[service].components);
369
+ // Filter refs that are already in service components
370
+ const filteredRefs = new Set();
371
+ for (const ref of intRefs) {
372
+ const parts = ref.split('/');
373
+ if (parts.length >= 4 && parts[1] === "components" && parts[2] === "schemas" &&
374
+ !services[service].components.schemas[parts[3]]) {
375
+ filteredRefs.add(ref);
376
+ }
377
+ }
378
+
379
+ if (verbose) {
380
+ logger.debug(`Found ${filteredRefs.size} INTERNAL schema refs for service ${service}`);
381
+ }
382
+
383
+ if (filteredRefs.size > 0) {
384
+ if (verbose) {
385
+ logger.debug(`Adding ${filteredRefs.size} INTERNAL schema refs for service ${service}`);
386
+ }
387
+ addRefsToComponents(filteredRefs, services[service], apiDocObj.components || {}, verbose);
388
+ } else {
389
+ if (verbose) {
390
+ logger.debug(`Exiting INTERNAL schema refs for ${service}`);
391
+ }
392
+ break;
393
+ }
394
+ }
395
+ }
396
+ }
397
+
398
+ // Add non-operations to each service
399
+ for (const service in services) {
400
+ for (const pathKey of Object.keys(services[service].paths)) {
401
+ if (verbose) {
402
+ logger.debug(`Adding non operations to ${service} for path ${pathKey}`);
403
+ }
404
+
405
+ for (const nonOp of NON_OPERATIONS) {
406
+ if (verbose) {
407
+ logger.debug(`Looking for non operation ${nonOp} in ${service} under path ${pathKey}`);
408
+ }
409
+
410
+ if (apiPaths[pathKey] && apiPaths[pathKey][nonOp]) {
411
+ if (verbose) {
412
+ logger.debug(`Adding ${nonOp} to ${service} for path ${pathKey}`);
413
+ }
414
+
415
+ // Special case for parameters
416
+ if (nonOp === 'parameters') {
417
+ for (const verbKey in services[service].paths[pathKey]) {
418
+ services[service].paths[pathKey][verbKey].parameters = apiPaths[pathKey].parameters;
419
+ }
420
+ }
421
+ }
422
+ }
423
+ }
424
+ }
425
+
426
+ // Update path param names (replace hyphens with underscores)
427
+ for (const service in services) {
428
+ if (services[service].paths) {
429
+ const pathKeys = Object.keys(services[service].paths);
430
+ for (const pathKey of pathKeys) {
431
+ if (verbose) {
432
+ logger.debug(`Renaming path params in ${service} for path ${pathKey}`);
433
+ }
434
+
435
+ // Replace hyphens with underscores in path parameters
436
+ const updatedPathKey = pathKey.replace(/(?<=\{)([^}]+?)-([^}]+?)(?=\})/g, '$1_$2');
437
+
438
+ if (updatedPathKey !== pathKey) {
439
+ if (verbose) {
440
+ logger.debug(`Updated path key from ${pathKey} to ${updatedPathKey}`);
441
+ }
442
+
443
+ services[service].paths[updatedPathKey] = services[service].paths[pathKey];
444
+ delete services[service].paths[pathKey];
445
+
446
+ // Also update parameter names in operations
447
+ for (const verbKey in services[service].paths[updatedPathKey]) {
448
+ const operation = services[service].paths[updatedPathKey][verbKey];
449
+ if (operation.parameters) {
450
+ for (const param of operation.parameters) {
451
+ if (param.in === 'path' && param.name.includes('-')) {
452
+ const originalName = param.name;
453
+ param.name = param.name.replace(/-/g, '_');
454
+ if (verbose) {
455
+ logger.debug(`Updated parameter name from ${originalName} to ${param.name} in path ${updatedPathKey}`);
456
+ }
457
+ }
458
+ }
459
+ }
460
+ }
461
+ }
462
+ }
463
+ }
464
+ }
465
+
466
+ // Fix missing type: object
467
+ for (const service in services) {
468
+ if (verbose) {
469
+ logger.debug(`Updating paths for ${service}`);
470
+ }
471
+ services[service].paths = addMissingObjectTypes(services[service].paths);
472
+ services[service].components = addMissingObjectTypes(services[service].components);
473
+ }
474
+
475
+ // Write out service docs
476
+ for (const service in services) {
477
+ logger.info(`✅ Writing out OpenAPI doc for [${service}]`);
478
+
479
+ // const svcDir = path.join(outputDir, service);
480
+ // const outputFile = path.join(svcDir, `${service}.yaml`);
481
+ const outputFile = path.join(outputDir, `${service}.yaml`);
482
+ // fs.mkdirSync(svcDir, { recursive: true });
483
+
484
+ fs.writeFileSync(outputFile, yaml.dump(services[service], {
485
+ noRefs: true,
486
+ sortKeys: false
487
+ }));
488
+ }
489
+
490
+ logger.info(`🎉 Successfully split OpenAPI doc into ${Object.keys(services).length} services`);
491
+ return true;
492
+ }
package/src/utils.js ADDED
@@ -0,0 +1,42 @@
1
+ // Add to utils.js at the top:
2
+ import fs from 'fs';
3
+ import logger from './logger.js';
4
+
5
+ /**
6
+ * Convert camelCase to snake_case
7
+ * @param {string} name - The string to convert
8
+ * @returns {string} - Converted string in snake_case
9
+ */
10
+ export function camelToSnake(name) {
11
+ const s1 = name.replace(/([a-z0-9])([A-Z][a-z]+)/g, '$1_$2');
12
+ return s1.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
13
+ }
14
+
15
+ /**
16
+ * Create destination directory
17
+ * @param {string} destDir - Destination directory path
18
+ * @param {boolean} overwrite - Whether to overwrite existing directory
19
+ * @returns {boolean} - Success status
20
+ */
21
+ export function createDestDir(destDir, overwrite) {
22
+ if (fs.existsSync(destDir)) {
23
+ if (!overwrite) {
24
+ logger.error(`Destination directory ${destDir} already exists. Use --overwrite to force.`);
25
+ return false;
26
+ }
27
+
28
+ // Clean the directory if overwrite is true
29
+ try {
30
+ // Remove all files and subdirectories
31
+ fs.rmSync(destDir, { recursive: true, force: true });
32
+ logger.info(`Cleaned destination directory ${destDir}`);
33
+ } catch (error) {
34
+ logger.error(`Failed to clean destination directory ${destDir}: ${error.message}`);
35
+ return false;
36
+ }
37
+ }
38
+
39
+ // Create the directory (or recreate it if it was deleted)
40
+ fs.mkdirSync(destDir, { recursive: true });
41
+ return true;
42
+ }