@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,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,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
+ };