@stackql/provider-utils 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/package.json +2 -1
- package/src/index.js +3 -2
- package/src/logger.js +52 -0
- package/src/providerdev/analyze.js +169 -0
- package/src/providerdev/generate.js +307 -0
- package/src/providerdev/index.js +10 -0
- package/src/providerdev/split.js +492 -0
- package/src/utils.js +42 -0
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.
|
|
3
|
+
"version": "0.3.0",
|
|
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
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,169 @@
|
|
|
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
|
+
// Find SQL verb for this method
|
|
66
|
+
let sqlVerb = 'exec'; // Default if no explicit mapping
|
|
67
|
+
|
|
68
|
+
for (const [verb, methods] of Object.entries(resource.sqlVerbs || {})) {
|
|
69
|
+
for (const methodRef of methods) {
|
|
70
|
+
if (methodRef.$ref === `#/components/x-stackQL-resources/${resourceName}/methods/${methodName}`) {
|
|
71
|
+
sqlVerb = verb;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
resourceName,
|
|
79
|
+
methodName,
|
|
80
|
+
sqlVerb
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
resourceName: '',
|
|
88
|
+
methodName: '',
|
|
89
|
+
sqlVerb: ''
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Analyze OpenAPI specs and generate mapping CSV
|
|
95
|
+
* @param {Object} options - Options for analysis
|
|
96
|
+
* @returns {Promise<boolean>} - Success status
|
|
97
|
+
*/
|
|
98
|
+
export async function analyze(options) {
|
|
99
|
+
const {
|
|
100
|
+
inputDir,
|
|
101
|
+
outputDir
|
|
102
|
+
} = options;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
106
|
+
const outputPath = path.join(outputDir, 'all_services.csv');
|
|
107
|
+
|
|
108
|
+
const writer = fs.createWriteStream(outputPath, { encoding: 'utf8' });
|
|
109
|
+
|
|
110
|
+
// Write header
|
|
111
|
+
writer.write('filename,path,operationId,formatted_op_id,verb,response_object,tags,formatted_tags,stackql_resource_name,stackql_method_name,stackql_verb\n');
|
|
112
|
+
|
|
113
|
+
const files = fs.readdirSync(inputDir);
|
|
114
|
+
|
|
115
|
+
for (const filename of files) {
|
|
116
|
+
if (!filename.endsWith('.yaml') && !filename.endsWith('.yml') && !filename.endsWith('.json')) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const filepath = path.join(inputDir, filename);
|
|
121
|
+
const spec = loadSpec(filepath);
|
|
122
|
+
|
|
123
|
+
for (const [pathKey, pathItem] of Object.entries(spec.paths || {})) {
|
|
124
|
+
for (const [verb, operation] of Object.entries(pathItem)) {
|
|
125
|
+
if (typeof operation !== 'object' || operation === null) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const operationId = operation.operationId || '';
|
|
130
|
+
// Format operationId as snake_case
|
|
131
|
+
const formattedOpId = operationId ? camelToSnake(operationId) : '';
|
|
132
|
+
|
|
133
|
+
const responseObj = operation.responses || {};
|
|
134
|
+
const responseRef = extractMain2xxResponse(responseObj);
|
|
135
|
+
const tagsList = operation.tags || [];
|
|
136
|
+
const tagsStr = tagsList.join('|');
|
|
137
|
+
|
|
138
|
+
// Format tags as snake_case
|
|
139
|
+
const formattedTags = tagsList.map(tag => camelToSnake(tag)).join('|');
|
|
140
|
+
|
|
141
|
+
// Construct the path reference as it would appear in x-stackQL-resources
|
|
142
|
+
const encodedPath = pathKey.replace(/\//g, '~1');
|
|
143
|
+
const pathRef = `#/paths/${encodedPath}/${verb}`;
|
|
144
|
+
|
|
145
|
+
// Find existing mapping if available
|
|
146
|
+
const { resourceName, methodName, sqlVerb } = findExistingMapping(spec, pathRef);
|
|
147
|
+
|
|
148
|
+
// Escape commas in fields
|
|
149
|
+
const escapedPath = pathKey.includes(',') ? `"${pathKey}"` : pathKey;
|
|
150
|
+
const escapedOperationId = operationId.includes(',') ? `"${operationId}"` : operationId;
|
|
151
|
+
const escapedFormattedOpId = formattedOpId.includes(',') ? `"${formattedOpId}"` : formattedOpId;
|
|
152
|
+
const escapedTags = tagsStr.includes(',') ? `"${tagsStr}"` : tagsStr;
|
|
153
|
+
const escapedFormattedTags = formattedTags.includes(',') ? `"${formattedTags}"` : formattedTags;
|
|
154
|
+
|
|
155
|
+
// Write row
|
|
156
|
+
writer.write(`${filename},${escapedPath},${escapedOperationId},${escapedFormattedOpId},${verb},${responseRef},${escapedTags},${escapedFormattedTags},${resourceName},${methodName},${sqlVerb}\n`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
writer.end();
|
|
162
|
+
|
|
163
|
+
logger.info(`✅ Analysis complete. Output written to: ${outputPath}`);
|
|
164
|
+
return true;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
logger.error(`Failed to analyze OpenAPI specs: ${error.message}`);
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -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,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
|
+
}
|