@stackql/provider-utils 0.2.4 → 0.3.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.
package/README.md CHANGED
@@ -159,6 +159,11 @@ node tests/docgen/test-docgen.js google
159
159
  node tests/docgen/test-docgen.js homebrew
160
160
  ```
161
161
 
162
+
163
+ node tests/providerdev/test-split.js okta tests/providerdev/split-source/okta/management-minimal.yaml path
164
+ node tests/providerdev/test-analyze.js okta
165
+ node tests/providerdev/test-generate.js okta
166
+
162
167
  ## Using the Documentation Generator
163
168
 
164
169
  ### Basic Example
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackql/provider-utils",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "Utilities for building StackQL providers from OpenAPI specifications.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -36,6 +36,7 @@
36
36
  "dependencies": {
37
37
  "@apidevtools/swagger-parser": "^10.1.1",
38
38
  "@stackql/deno-openapi-dereferencer": "npm:@jsr/stackql__deno-openapi-dereferencer@^0.3.1",
39
+ "csv-parser": "^3.2.0",
39
40
  "js-yaml": "^4.1.0",
40
41
  "pluralize": "^8.0.0"
41
42
  },
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
- // src/index.js
1
+ // @stackql/provider-utils/src/index.js
2
2
 
3
3
  // Main entry point for Node.js
4
- export * as docgen from './docgen/index.js';
4
+ export * as docgen from './docgen/index.js';
5
+ export * as providerdev from './providerdev/index.js';
package/src/logger.js ADDED
@@ -0,0 +1,52 @@
1
+ // @stackql/provider-utils/src/logger.js
2
+
3
+ // Simple logger implementation
4
+ const logLevels = {
5
+ error: 0,
6
+ warn: 1,
7
+ info: 2,
8
+ debug: 3
9
+ };
10
+
11
+ let currentLevel = 'info';
12
+
13
+ const logger = {
14
+ get level() {
15
+ return currentLevel;
16
+ },
17
+
18
+ set level(newLevel) {
19
+ if (logLevels[newLevel] !== undefined) {
20
+ currentLevel = newLevel;
21
+ } else {
22
+ console.warn(`Invalid log level: ${newLevel}. Using 'info' instead.`);
23
+ currentLevel = 'info';
24
+ }
25
+ },
26
+
27
+ error: (message) => {
28
+ if (logLevels[currentLevel] >= logLevels.error) {
29
+ console.error(`ERROR: ${message}`);
30
+ }
31
+ },
32
+
33
+ warn: (message) => {
34
+ if (logLevels[currentLevel] >= logLevels.warn) {
35
+ console.warn(`WARNING: ${message}`);
36
+ }
37
+ },
38
+
39
+ info: (message) => {
40
+ if (logLevels[currentLevel] >= logLevels.info) {
41
+ console.info(`INFO: ${message}`);
42
+ }
43
+ },
44
+
45
+ debug: (message) => {
46
+ if (logLevels[currentLevel] >= logLevels.debug) {
47
+ console.debug(`DEBUG: ${message}`);
48
+ }
49
+ }
50
+ };
51
+
52
+ export default logger;
@@ -0,0 +1,172 @@
1
+ // src/providerdev/analyze.js
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import yaml from 'js-yaml';
5
+ import logger from '../logger.js';
6
+ import { camelToSnake } from '../utils.js';
7
+
8
+ /**
9
+ * Load specification from YAML or JSON file
10
+ * @param {string} filepath - Path to specification file
11
+ * @returns {Object} - Loaded specification
12
+ */
13
+ function loadSpec(filepath) {
14
+ const content = fs.readFileSync(filepath, 'utf-8');
15
+ if (filepath.endsWith('.json')) {
16
+ return JSON.parse(content);
17
+ }
18
+ return yaml.load(content);
19
+ }
20
+
21
+ /**
22
+ * Extract main 2xx response schema reference
23
+ * @param {Object} responseObj - Response object
24
+ * @returns {string} - Schema reference name
25
+ */
26
+ function extractMain2xxResponse(responseObj) {
27
+ for (const [code, response] of Object.entries(responseObj)) {
28
+ if (code.startsWith('2')) {
29
+ const content = response.content || {};
30
+ const appJson = content['application/json'] || {};
31
+ const schema = appJson.schema || {};
32
+
33
+ // Case 1: Direct $ref
34
+ if (schema.$ref) {
35
+ return schema.$ref.split('/').pop();
36
+ }
37
+
38
+ // Case 2: Array of items
39
+ if (schema.type === 'array') {
40
+ const items = schema.items || {};
41
+ if (items.$ref) {
42
+ return items.$ref.split('/').pop();
43
+ }
44
+ }
45
+
46
+ return '';
47
+ }
48
+ }
49
+ return '';
50
+ }
51
+
52
+ /**
53
+ * Find existing mapping in x-stackQL-resources
54
+ * @param {Object} spec - OpenAPI spec
55
+ * @param {string} pathRef - Reference to path item
56
+ * @returns {Object} - Mapping info (resource, method, verb)
57
+ */
58
+ function findExistingMapping(spec, pathRef) {
59
+ const stackQLResources = spec.components?.['x-stackQL-resources'] || {};
60
+
61
+ for (const [resourceName, resource] of Object.entries(stackQLResources)) {
62
+ // Check methods
63
+ for (const [methodName, method] of Object.entries(resource.methods || {})) {
64
+ if (method.operation?.$ref === pathRef) {
65
+ logger.info(`Found mapping for ${pathRef}: ${resourceName}.${methodName}`);
66
+ // Find SQL verb for this method
67
+ let sqlVerb = 'exec'; // Default if no explicit mapping
68
+
69
+ for (const [verb, methods] of Object.entries(resource.sqlVerbs || {})) {
70
+ for (const methodRef of methods) {
71
+ if (methodRef.$ref === `#/components/x-stackQL-resources/${resourceName}/methods/${methodName}`) {
72
+ sqlVerb = verb;
73
+ break;
74
+ }
75
+ }
76
+ }
77
+
78
+ return {
79
+ resourceName,
80
+ methodName,
81
+ sqlVerb
82
+ };
83
+ }
84
+ }
85
+ }
86
+
87
+ logger.info(`No mapping for ${pathRef}`);
88
+
89
+ return {
90
+ resourceName: '',
91
+ methodName: '',
92
+ sqlVerb: ''
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Analyze OpenAPI specs and generate mapping CSV
98
+ * @param {Object} options - Options for analysis
99
+ * @returns {Promise<boolean>} - Success status
100
+ */
101
+ export async function analyze(options) {
102
+ const {
103
+ inputDir,
104
+ outputDir
105
+ } = options;
106
+
107
+ try {
108
+ fs.mkdirSync(outputDir, { recursive: true });
109
+ const outputPath = path.join(outputDir, 'all_services.csv');
110
+
111
+ const writer = fs.createWriteStream(outputPath, { encoding: 'utf8' });
112
+
113
+ // Write header
114
+ writer.write('filename,path,operationId,formatted_op_id,verb,response_object,tags,formatted_tags,stackql_resource_name,stackql_method_name,stackql_verb\n');
115
+
116
+ const files = fs.readdirSync(inputDir);
117
+
118
+ for (const filename of files) {
119
+ if (!filename.endsWith('.yaml') && !filename.endsWith('.yml') && !filename.endsWith('.json')) {
120
+ continue;
121
+ }
122
+
123
+ const filepath = path.join(inputDir, filename);
124
+ const spec = loadSpec(filepath);
125
+
126
+ for (const [pathKey, pathItem] of Object.entries(spec.paths || {})) {
127
+ for (const [verb, operation] of Object.entries(pathItem)) {
128
+ if (typeof operation !== 'object' || operation === null) {
129
+ continue;
130
+ }
131
+
132
+ const operationId = operation.operationId || '';
133
+ // Format operationId as snake_case
134
+ const formattedOpId = operationId ? camelToSnake(operationId) : '';
135
+
136
+ const responseObj = operation.responses || {};
137
+ const responseRef = extractMain2xxResponse(responseObj);
138
+ const tagsList = operation.tags || [];
139
+ const tagsStr = tagsList.join('|');
140
+
141
+ // Format tags as snake_case
142
+ const formattedTags = tagsList.map(tag => camelToSnake(tag)).join('|');
143
+
144
+ // Construct the path reference as it would appear in x-stackQL-resources
145
+ const encodedPath = pathKey.replace(/\//g, '~1');
146
+ const pathRef = `#/paths/${encodedPath}/${verb}`;
147
+
148
+ // Find existing mapping if available
149
+ const { resourceName, methodName, sqlVerb } = findExistingMapping(spec, pathRef);
150
+
151
+ // Escape commas in fields
152
+ const escapedPath = pathKey.includes(',') ? `"${pathKey}"` : pathKey;
153
+ const escapedOperationId = operationId.includes(',') ? `"${operationId}"` : operationId;
154
+ const escapedFormattedOpId = formattedOpId.includes(',') ? `"${formattedOpId}"` : formattedOpId;
155
+ const escapedTags = tagsStr.includes(',') ? `"${tagsStr}"` : tagsStr;
156
+ const escapedFormattedTags = formattedTags.includes(',') ? `"${formattedTags}"` : formattedTags;
157
+
158
+ // Write row
159
+ writer.write(`${filename},${escapedPath},${escapedOperationId},${escapedFormattedOpId},${verb},${responseRef},${escapedTags},${escapedFormattedTags},${resourceName},${methodName},${sqlVerb}\n`);
160
+ }
161
+ }
162
+ }
163
+
164
+ writer.end();
165
+
166
+ logger.info(`✅ Analysis complete. Output written to: ${outputPath}`);
167
+ return true;
168
+ } catch (error) {
169
+ logger.error(`Failed to analyze OpenAPI specs: ${error.message}`);
170
+ return false;
171
+ }
172
+ }
@@ -0,0 +1,307 @@
1
+ // src/providerdev/generate.js
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import yaml from 'js-yaml';
5
+ import csv from 'csv-parser';
6
+ import logger from '../logger.js';
7
+ import { createReadStream } from 'fs';
8
+
9
+ /**
10
+ * Load manifest from CSV file
11
+ * @param {string} configPath - Path to CSV config file
12
+ * @returns {Promise<Object>} - Manifest object
13
+ */
14
+ async function loadManifest(configPath) {
15
+ const manifest = {};
16
+
17
+ return new Promise((resolve, reject) => {
18
+ createReadStream(configPath)
19
+ .pipe(csv())
20
+ .on('data', (row) => {
21
+ const key = `${row.filename}::${row.operationId}`;
22
+ manifest[key] = row;
23
+ })
24
+ .on('end', () => {
25
+ resolve(manifest);
26
+ })
27
+ .on('error', (error) => {
28
+ reject(error);
29
+ });
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Load specification from YAML or JSON file
35
+ * @param {string} filepath - Path to specification file
36
+ * @returns {Object} - Loaded specification
37
+ */
38
+ function loadSpec(filepath) {
39
+ const content = fs.readFileSync(filepath, 'utf-8');
40
+ if (filepath.endsWith('.json')) {
41
+ return JSON.parse(content);
42
+ }
43
+ return yaml.load(content);
44
+ }
45
+
46
+ /**
47
+ * Write specification to file
48
+ * @param {string} filepath - Output file path
49
+ * @param {Object} data - Data to write
50
+ */
51
+ function writeSpec(filepath, data) {
52
+ fs.writeFileSync(filepath, yaml.dump(data, { sortKeys: false }));
53
+ }
54
+
55
+ /**
56
+ * Encode reference path
57
+ * @param {string} path - HTTP path
58
+ * @param {string} verb - HTTP verb
59
+ * @returns {string} - Encoded reference path
60
+ */
61
+ function encodeRefPath(path, verb) {
62
+ const encodedPath = path.replace(/\//g, '~1');
63
+ return `#/paths/${encodedPath}/${verb}`;
64
+ }
65
+
66
+ /**
67
+ * Get success response information
68
+ * @param {Object} operation - Operation object
69
+ * @returns {Object} - Response information
70
+ */
71
+ function getSuccessResponseInfo(operation) {
72
+ const responses = operation.responses || {};
73
+ const twoXxCodes = Object.keys(responses)
74
+ .filter(code => code.startsWith('2'))
75
+ .sort();
76
+
77
+ if (twoXxCodes.length === 0) {
78
+ return { mediaType: '', openAPIDocKey: '' };
79
+ }
80
+
81
+ const lowest2xx = twoXxCodes[0];
82
+ const content = responses[lowest2xx]?.content || {};
83
+ const mediaTypes = Object.keys(content);
84
+
85
+ const mediaType = mediaTypes.length > 0 ? mediaTypes[0] : '';
86
+
87
+ return {
88
+ mediaType,
89
+ openAPIDocKey: lowest2xx
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Convert string to snake_case
95
+ * @param {string} name - String to convert
96
+ * @returns {string} - Converted string
97
+ */
98
+ function snakeCase(name) {
99
+ return name.replace(/-/g, '_');
100
+ }
101
+
102
+ /**
103
+ * Generate StackQL provider extensions
104
+ * @param {Object} options - Options for generation
105
+ * @returns {Promise<boolean>} - Success status
106
+ */
107
+ export async function generate(options) {
108
+ const {
109
+ inputDir,
110
+ outputDir,
111
+ configPath,
112
+ providerId,
113
+ servers = null,
114
+ providerConfig = null,
115
+ skipFiles = []
116
+ } = options;
117
+
118
+ const version = 'v00.00.00000';
119
+ const servicesPath = path.join(outputDir, version, 'services');
120
+
121
+ // Create directories
122
+ fs.mkdirSync(servicesPath, { recursive: true });
123
+
124
+ // Clean all files in services output dir
125
+ try {
126
+ const files = fs.readdirSync(servicesPath);
127
+ for (const file of files) {
128
+ const filePath = path.join(servicesPath, file);
129
+ if (fs.statSync(filePath).isFile()) {
130
+ fs.unlinkSync(filePath);
131
+ }
132
+ }
133
+ logger.info(`🧹 Cleared all files in ${servicesPath}`);
134
+ } catch (error) {
135
+ logger.error(`Failed to clear files in ${servicesPath}: ${error.message}`);
136
+ return false;
137
+ }
138
+
139
+ // Delete provider.yaml file
140
+ const providerManifestFile = path.join(outputDir, version, 'provider.yaml');
141
+ if (fs.existsSync(providerManifestFile)) {
142
+ fs.unlinkSync(providerManifestFile);
143
+ logger.info(`🧹 Deleted ${providerManifestFile}`);
144
+ }
145
+
146
+ // Load manifest
147
+ let manifest;
148
+ try {
149
+ manifest = await loadManifest(configPath);
150
+ } catch (error) {
151
+ logger.error(`Failed to load manifest: ${error.message}`);
152
+ return false;
153
+ }
154
+
155
+ const providerServices = {};
156
+
157
+ try {
158
+ const files = fs.readdirSync(inputDir);
159
+
160
+ for (const filename of files) {
161
+ if (skipFiles.includes(filename)) {
162
+ logger.info(`⭐️ Skipping ${filename} (matched --skip)`);
163
+ continue;
164
+ }
165
+
166
+ if (!filename.endsWith('.yaml') && !filename.endsWith('.yml') && !filename.endsWith('.json')) {
167
+ continue;
168
+ }
169
+
170
+ const baseName = path.basename(filename, path.extname(filename));
171
+ const serviceName = snakeCase(baseName);
172
+
173
+ console.log(`processing service: ${serviceName}`);
174
+
175
+ const specPath = path.join(inputDir, filename);
176
+ const spec = loadSpec(specPath);
177
+
178
+ // Initialize resources object with defaultdict-like behavior
179
+ const resources = {};
180
+
181
+ for (const [pathKey, pathItem] of Object.entries(spec.paths || {})) {
182
+ for (const [verb, operation] of Object.entries(pathItem)) {
183
+ if (typeof operation !== 'object' || operation === null) {
184
+ continue;
185
+ }
186
+
187
+ const operationId = operation.operationId;
188
+ if (!operationId) {
189
+ continue;
190
+ }
191
+
192
+ const manifestKey = `${filename}::${operationId}`;
193
+ const entry = manifest[manifestKey];
194
+ if (!entry) {
195
+ logger.error(`❌ ERROR: ${filename} → ${operationId} not found in manifest`);
196
+ return false;
197
+ }
198
+
199
+ const resource = entry.stackql_resource_name;
200
+ const method = entry.stackql_method_name;
201
+ const sqlverb = entry.stackql_verb;
202
+
203
+ // Initialize resource if it doesn't exist
204
+ if (!resources[resource]) {
205
+ resources[resource] = {
206
+ id: `${providerId}.${serviceName}.${resource}`,
207
+ name: resource,
208
+ title: resource.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
209
+ methods: {},
210
+ sqlVerbs: {
211
+ select: [],
212
+ insert: [],
213
+ update: [],
214
+ delete: [],
215
+ replace: []
216
+ }
217
+ };
218
+ }
219
+
220
+ const pathRef = encodeRefPath(pathKey, verb);
221
+ const responseInfo = getSuccessResponseInfo(operation);
222
+
223
+ const methodEntry = {
224
+ operation: { $ref: pathRef },
225
+ response: responseInfo
226
+ };
227
+
228
+ resources[resource].methods[method] = methodEntry;
229
+ if (sqlverb && sqlverb === 'exec') {
230
+ logger.info(`exec method skipped: ${resource}.${method}`);
231
+ } else if (sqlverb && resources[resource].sqlVerbs[sqlverb]) {
232
+ resources[resource].sqlVerbs[sqlverb].push({
233
+ $ref: `#/components/x-stackQL-resources/${resource}/methods/${method}`
234
+ });
235
+ } else if (sqlverb) {
236
+ logger.warn(`⚠️ Unknown SQL verb '${sqlverb}' for ${resource}.${method}, skipping`);
237
+ }
238
+ }
239
+ }
240
+
241
+ // Inject into spec
242
+ if (!spec.components) {
243
+ spec.components = {};
244
+ }
245
+ spec.components['x-stackQL-resources'] = resources;
246
+
247
+ // Inject servers if provided
248
+ if (servers) {
249
+ try {
250
+ const serversJson = JSON.parse(servers);
251
+ spec.servers = serversJson;
252
+ } catch (error) {
253
+ logger.error(`❌ Failed to parse servers JSON: ${error.message}`);
254
+ return false;
255
+ }
256
+ }
257
+
258
+ // Write enriched spec
259
+ const outputPath = path.join(servicesPath, filename);
260
+ writeSpec(outputPath, spec);
261
+ logger.info(`✅ Wrote enriched spec: ${outputPath}`);
262
+
263
+ // Add providerService entry
264
+ const info = spec.info || {};
265
+ const specTitle = info.title || `${serviceName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())} API`;
266
+ const specDescription = info.description || `TODO: add description for ${serviceName}`;
267
+
268
+ providerServices[serviceName] = {
269
+ id: `${serviceName}:${version}`,
270
+ name: serviceName,
271
+ preferred: true,
272
+ service: {
273
+ $ref: `${providerId}/${version}/services/${filename}`
274
+ },
275
+ title: specTitle,
276
+ version: version,
277
+ description: specDescription
278
+ };
279
+ }
280
+
281
+ // Write provider.yaml
282
+ const providerYaml = {
283
+ id: providerId,
284
+ name: providerId,
285
+ version: version,
286
+ providerServices: providerServices,
287
+ };
288
+
289
+ if (providerConfig) {
290
+ try {
291
+ const providerConfigJson = JSON.parse(providerConfig);
292
+ providerYaml.config = providerConfigJson;
293
+ } catch (error) {
294
+ logger.error(`❌ Failed to parse provider config JSON: ${error.message}`);
295
+ return false;
296
+ }
297
+ }
298
+
299
+ writeSpec(path.join(outputDir, version, 'provider.yaml'), providerYaml);
300
+ logger.info(`📦 Wrote provider.yaml to ${outputDir}/${version}/provider.yaml`);
301
+
302
+ return true;
303
+ } catch (error) {
304
+ logger.error(`Failed to generate provider: ${error.message}`);
305
+ return false;
306
+ }
307
+ }
@@ -0,0 +1,10 @@
1
+ // src/providerdev/index.js
2
+ import { split } from './split.js';
3
+ import { generate } from './generate.js';
4
+ import { analyze } from './analyze.js';
5
+
6
+ export {
7
+ split,
8
+ generate,
9
+ analyze
10
+ };
@@ -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
+ }