@stackql/provider-utils 0.4.1 → 0.4.3

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
@@ -1,6 +1,7 @@
1
1
  # StackQL Provider Utils
2
2
 
3
- ![NPM Version](https://img.shields.io/npm/v/%40stackql%2Fprovider-utils) | ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/stackql/stackql/total?style=plastic&label=stackql%20downloads)
3
+ ![NPM Version](https://img.shields.io/npm/v/%40stackql%2Fprovider-utils)
4
+ ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/stackql/stackql/total?style=plastic&label=stackql%20downloads)
4
5
 
5
6
  A comprehensive toolkit for transforming OpenAPI specifications into StackQL providers. This library streamlines the process of parsing, mapping, validating, testing, and generating documentation for StackQL providers.
6
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackql/provider-utils",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Utilities for building StackQL providers from OpenAPI specifications.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -4,6 +4,24 @@ import {
4
4
  sanitizeHtml
5
5
  } from '../helpers.js';
6
6
 
7
+ const getRequiredBodyParams = (methodDetails, accessType) => {
8
+ // Only process request body for insert, update, replace, and exec
9
+ if (!['insert', 'update', 'replace', 'exec'].includes(accessType)) {
10
+ return [];
11
+ }
12
+
13
+ // Get required body params if they exist
14
+ const requiredBodyProps = methodDetails.requestBody?.required ? methodDetails.requestBody.required : [];
15
+
16
+ // For insert, update, and replace, prefix with data__
17
+ if (['insert', 'update', 'replace'].includes(accessType)) {
18
+ return requiredBodyProps.map(prop => `data__${prop}`);
19
+ } else {
20
+ // For exec, don't prefix
21
+ return requiredBodyProps;
22
+ }
23
+ };
24
+
7
25
  export function createMethodsSection(resourceData, dereferencedAPI) {
8
26
 
9
27
  let content = `\n## Methods\n\n`;
@@ -37,10 +55,16 @@ export function createMethodsSection(resourceData, dereferencedAPI) {
37
55
  for (const [methodName, methodDetails] of Object.entries(methods)) {
38
56
  console.info(`Adding ${accessType} method to table: ${methodName}`);
39
57
 
58
+ // Get required params from both the standard params and the request body
59
+ const reqParamsArr = Object.keys(methodDetails.requiredParams || {});
60
+ const reqBodyParamsArr = getRequiredBodyParams(methodDetails, accessType);
61
+
62
+ // Combine both types of required parameters
63
+ const allReqParamsArr = [...reqParamsArr, ...reqBodyParamsArr];
64
+
40
65
  // Format required params as comma-delimited list with hyperlinks
41
- const requiredParamsArr = Object.keys(methodDetails.requiredParams || {});
42
- const requiredParamsStr = requiredParamsArr.length > 0
43
- ? requiredParamsArr.map(param => `<a href="#parameter-${param}"><code>${param}</code></a>`).join(', ')
66
+ const requiredParamsStr = allReqParamsArr.length > 0
67
+ ? allReqParamsArr.map(param => `<a href="#parameter-${param}"><code>${param}</code></a>`).join(', ')
44
68
  : '';
45
69
 
46
70
  // Format optional params as comma-delimited list with hyperlinks
@@ -48,7 +72,7 @@ export function createMethodsSection(resourceData, dereferencedAPI) {
48
72
  const optionalParamsStr = optionalParamsArr.length > 0
49
73
  ? optionalParamsArr.map(param => `<a href="#parameter-${param}"><code>${param}</code></a>`).join(', ')
50
74
  : '';
51
-
75
+
52
76
  // Add the method row to the table
53
77
  content += `
54
78
  <tr>
@@ -51,6 +51,108 @@ function extractMain2xxResponse(responseObj) {
51
51
  return '';
52
52
  }
53
53
 
54
+ /**
55
+ * Detect if an object should have an objectKey and determine what it should be
56
+ * @param {Object} spec - Full OpenAPI spec
57
+ * @param {Object} operation - Operation object
58
+ * @returns {string} - Suggested objectKey or empty string if none
59
+ */
60
+ function detectObjectKey(spec, operation) {
61
+ // Only applicable for GET operations
62
+ if (!operation || !operation.responses) {
63
+ return '';
64
+ }
65
+
66
+ let responseObject = null;
67
+ let responseCode = null;
68
+
69
+ // Find the first 2xx response
70
+ for (const [code, response] of Object.entries(operation.responses)) {
71
+ if (code.startsWith('2')) {
72
+ responseCode = code;
73
+ // Handle direct response or reference
74
+ if (response.$ref) {
75
+ // Resolve reference
76
+ const refParts = response.$ref.split('/');
77
+ const componentType = refParts[2];
78
+ const responseName = refParts[3];
79
+ responseObject = spec.components?.[componentType]?.[responseName];
80
+ } else {
81
+ responseObject = response;
82
+ }
83
+ break;
84
+ }
85
+ }
86
+
87
+ if (!responseObject) {
88
+ return '';
89
+ }
90
+
91
+ // Get the schema from the response
92
+ let schema = null;
93
+
94
+ // If it's a direct response object
95
+ if (responseObject.content?.['application/json']?.schema) {
96
+ schema = responseObject.content['application/json'].schema;
97
+ }
98
+
99
+ // If the schema is a reference, resolve it
100
+ if (schema && schema.$ref) {
101
+ const refParts = schema.$ref.split('/');
102
+ const componentType = refParts[2];
103
+ const schemaName = refParts[3];
104
+ schema = spec.components?.[componentType]?.[schemaName];
105
+ }
106
+
107
+ // Handle allOf case (like in the droplets_list example)
108
+ if (schema && schema.allOf) {
109
+ // Look at the first object in allOf that has properties
110
+ for (const subSchema of schema.allOf) {
111
+ if (subSchema.properties && Object.keys(subSchema.properties).length === 1) {
112
+ const key = Object.keys(subSchema.properties)[0];
113
+ return `$.${key}`;
114
+ }
115
+ }
116
+ }
117
+
118
+ // Handle direct properties case (like in the droplets_get example)
119
+ if (schema && schema.properties) {
120
+ // If there's only one property at the top level, and it's not a primitive
121
+ const propKeys = Object.keys(schema.properties);
122
+ if (propKeys.length === 1) {
123
+ const key = propKeys[0];
124
+ const prop = schema.properties[key];
125
+
126
+ // Check if the property is an object or array, not a primitive
127
+ if (prop.$ref ||
128
+ prop.type === 'object' ||
129
+ prop.type === 'array' ||
130
+ (prop.properties && Object.keys(prop.properties).length > 0)) {
131
+ return `$.${key}`;
132
+ }
133
+ }
134
+ }
135
+
136
+ return '';
137
+ }
138
+
139
+ /**
140
+ * Map HTTP verb to SQL verb
141
+ * @param {string} httpVerb - HTTP verb (get, post, put, etc)
142
+ * @returns {string} - Corresponding SQL verb
143
+ */
144
+ function mapToSqlVerb(httpVerb) {
145
+ const verbMap = {
146
+ 'get': 'select',
147
+ 'post': 'insert',
148
+ 'delete': 'delete',
149
+ 'put': 'replace',
150
+ 'patch': 'update'
151
+ };
152
+
153
+ return verbMap[httpVerb] || 'exec';
154
+ }
155
+
54
156
  /**
55
157
  * Find existing mapping in x-stackQL-resources
56
158
  * @param {Object} spec - OpenAPI spec
@@ -77,10 +179,14 @@ function findExistingMapping(spec, pathRef) {
77
179
  }
78
180
  }
79
181
 
182
+ // Get objectKey if present
183
+ const objectKey = method.response?.objectKey || '';
184
+
80
185
  return {
81
186
  resourceName,
82
187
  methodName,
83
- sqlVerb
188
+ sqlVerb,
189
+ objectKey
84
190
  };
85
191
  }
86
192
  }
@@ -91,10 +197,28 @@ function findExistingMapping(spec, pathRef) {
91
197
  return {
92
198
  resourceName: '',
93
199
  methodName: '',
94
- sqlVerb: ''
200
+ sqlVerb: '',
201
+ objectKey: ''
95
202
  };
96
203
  }
97
204
 
205
+ /**
206
+ * Escape and sanitize a CSV field value
207
+ * @param {string} value - Field value to escape
208
+ * @returns {string} - Escaped value
209
+ */
210
+ function escapeCsvField(value) {
211
+ if (!value) return '';
212
+
213
+ // If the value contains commas, double quotes, or newlines, wrap it in quotes
214
+ // and escape any existing double quotes by doubling them
215
+ if (value.includes(',') || value.includes('"') || value.includes('\n')) {
216
+ return `"${value.replace(/"/g, '""')}"`;
217
+ }
218
+
219
+ return value;
220
+ }
221
+
98
222
  /**
99
223
  * Analyze OpenAPI specs and generate mapping CSV
100
224
  * @param {Object} options - Options for analysis
@@ -133,7 +257,8 @@ export async function analyze(options) {
133
257
  existingMappings[key] = {
134
258
  resourceName: row.stackql_resource_name || '',
135
259
  methodName: row.stackql_method_name || '',
136
- sqlVerb: row.stackql_verb || ''
260
+ sqlVerb: row.stackql_verb || '',
261
+ objectKey: row.stackql_object_key || ''
137
262
  };
138
263
  }
139
264
  })
@@ -159,7 +284,7 @@ export async function analyze(options) {
159
284
 
160
285
  // Only write header if creating a new file
161
286
  if (!fileExists) {
162
- writer.write('filename,path,operationId,formatted_op_id,verb,response_object,tags,formatted_tags,stackql_resource_name,stackql_method_name,stackql_verb\n');
287
+ writer.write('filename,path,operationId,formatted_op_id,verb,response_object,tags,formatted_tags,stackql_resource_name,stackql_method_name,stackql_verb,stackql_object_key,op_description\n');
163
288
  }
164
289
 
165
290
  const files = fs.readdirSync(inputDir);
@@ -214,17 +339,45 @@ export async function analyze(options) {
214
339
  const pathRef = `#/paths/${encodedPath}/${verb}`;
215
340
 
216
341
  // Find existing mapping if available
217
- const { resourceName, methodName, sqlVerb } = findExistingMapping(spec, pathRef);
342
+ let { resourceName, methodName, sqlVerb, objectKey } = findExistingMapping(spec, pathRef);
343
+
344
+ // CHANGE 1: Default methodName to formattedOpId if not found
345
+ if (!methodName) {
346
+ methodName = formattedOpId;
347
+ }
348
+
349
+ // CHANGE 2: Default sqlVerb based on HTTP verb if not found
350
+ if (!sqlVerb) {
351
+ sqlVerb = mapToSqlVerb(verb);
352
+ }
353
+
354
+ // CHANGE 3: Detect and set objectKey for GET operations if not already set
355
+ if (!objectKey && verb === 'get') {
356
+ objectKey = detectObjectKey(spec, operation);
357
+ }
358
+
359
+ // Get operation description
360
+ const opDescription = operation.summary || operation.description || '';
218
361
 
219
- // Escape commas in fields
220
- const escapedPath = pathKey.includes(',') ? `"${pathKey}"` : pathKey;
221
- const escapedOperationId = operationId.includes(',') ? `"${operationId}"` : operationId;
222
- const escapedFormattedOpId = formattedOpId.includes(',') ? `"${formattedOpId}"` : formattedOpId;
223
- const escapedTags = tagsStr.includes(',') ? `"${tagsStr}"` : tagsStr;
224
- const escapedFormattedTags = formattedTags.includes(',') ? `"${formattedTags}"` : formattedTags;
362
+ // Escape fields that might contain commas, quotes, or other special characters
363
+ const escapedFields = {
364
+ filename: escapeCsvField(filename),
365
+ path: escapeCsvField(pathKey),
366
+ operationId: escapeCsvField(operationId),
367
+ formattedOpId: escapeCsvField(formattedOpId),
368
+ verb: escapeCsvField(verb),
369
+ responseRef: escapeCsvField(responseRef),
370
+ tagsStr: escapeCsvField(tagsStr),
371
+ formattedTags: escapeCsvField(formattedTags),
372
+ resourceName: escapeCsvField(resourceName),
373
+ methodName: escapeCsvField(methodName),
374
+ sqlVerb: escapeCsvField(sqlVerb),
375
+ objectKey: escapeCsvField(objectKey),
376
+ opDescription: escapeCsvField(opDescription)
377
+ };
225
378
 
226
379
  // Write row
227
- writer.write(`${filename},${escapedPath},${escapedOperationId},${escapedFormattedOpId},${verb},${responseRef},${escapedTags},${escapedFormattedTags},${resourceName},${methodName},${sqlVerb}\n`);
380
+ writer.write(`${escapedFields.filename},${escapedFields.path},${escapedFields.operationId},${escapedFields.formattedOpId},${escapedFields.verb},${escapedFields.responseRef},${escapedFields.tagsStr},${escapedFields.formattedTags},${escapedFields.resourceName},${escapedFields.methodName},${escapedFields.sqlVerb},${escapedFields.objectKey},${escapedFields.opDescription}\n`);
228
381
  }
229
382
  }
230
383
  }
@@ -1,4 +1,3 @@
1
- // src/providerdev/generate.js
2
1
  import fs from 'fs';
3
2
  import path from 'path';
4
3
  import yaml from 'js-yaml';
@@ -75,14 +74,15 @@ function getSuccessResponseInfo(operation) {
75
74
  .sort();
76
75
 
77
76
  if (twoXxCodes.length === 0) {
78
- return { mediaType: '', openAPIDocKey: '' };
77
+ throw new Error('No 2xx response found, openAPIDocKey is required');
79
78
  }
80
79
 
81
80
  const lowest2xx = twoXxCodes[0];
82
81
  const content = responses[lowest2xx]?.content || {};
83
82
  const mediaTypes = Object.keys(content);
84
83
 
85
- const mediaType = mediaTypes.length > 0 ? mediaTypes[0] : '';
84
+ // Default to 'application/json' if mediaType is not found
85
+ const mediaType = mediaTypes.length > 0 ? mediaTypes[0] : 'application/json';
86
86
 
87
87
  return {
88
88
  mediaType,
@@ -99,6 +99,71 @@ function snakeCase(name) {
99
99
  return name.replace(/-/g, '_');
100
100
  }
101
101
 
102
+ /**
103
+ * Count the number of path parameters in a path
104
+ * @param {string} path - HTTP path
105
+ * @returns {number} - Number of path parameters
106
+ */
107
+ function countPathParams(path) {
108
+ // Match all path parameters like {param_name}
109
+ const matches = path.match(/\{[^}]+\}/g);
110
+ return matches ? matches.length : 0;
111
+ }
112
+
113
+ /**
114
+ * Sort operations from most specific to least specific based on path parameters
115
+ * @param {Object} resources - Resources object containing methods and sqlVerbs
116
+ * @param {Object} spec - Full OpenAPI specification
117
+ * @returns {Object} - Resources with sorted sqlVerbs
118
+ */
119
+ function sortOperationsBySpecificity(resources, spec) {
120
+ // For each resource
121
+ for (const resourceName in resources) {
122
+ const resource = resources[resourceName];
123
+ const methods = resource.methods;
124
+
125
+ // Create a map of method references to their specificity (path param count)
126
+ const methodSpecificityMap = {};
127
+
128
+ // For each method, find its operation ref and count path params
129
+ for (const methodName in methods) {
130
+ const method = methods[methodName];
131
+ const operationRef = method.operation.$ref;
132
+
133
+ // Extract path and verb from the reference
134
+ // Reference format: '#/paths/{encodedPath}/{verb}'
135
+ const refParts = operationRef.split('/');
136
+ const verb = refParts.pop();
137
+ // Remove '#/paths/' and the verb, then decode the path
138
+ const encodedPath = refParts.slice(2).join('/');
139
+ const path = encodedPath.replace(/~1/g, '/');
140
+
141
+ // Count path parameters
142
+ const paramCount = countPathParams(path);
143
+
144
+ // Store the method reference and its path parameter count
145
+ const methodRef = `#/components/x-stackQL-resources/${resourceName}/methods/${methodName}`;
146
+ methodSpecificityMap[methodRef] = paramCount;
147
+ }
148
+
149
+ // For each SQL verb, sort the operations by specificity
150
+ for (const verbName in resource.sqlVerbs) {
151
+ const operations = resource.sqlVerbs[verbName];
152
+
153
+ if (operations && operations.length > 0) {
154
+ // Sort operations from most specific (more path params) to least specific
155
+ operations.sort((a, b) => {
156
+ const aRef = a.$ref;
157
+ const bRef = b.$ref;
158
+ return methodSpecificityMap[bRef] - methodSpecificityMap[aRef];
159
+ });
160
+ }
161
+ }
162
+ }
163
+
164
+ return resources;
165
+ }
166
+
102
167
  /**
103
168
  * Generate StackQL provider extensions
104
169
  * @param {Object} options - Options for generation
@@ -234,6 +299,11 @@ export async function generate(options) {
234
299
  response: responseInfo
235
300
  };
236
301
 
302
+ // Add objectKey to the response info if it exists in the manifest and is for a GET operation
303
+ if (entry.stackql_object_key && verb === 'get') {
304
+ methodEntry.response.objectKey = entry.stackql_object_key;
305
+ }
306
+
237
307
  resources[resource].methods[method] = methodEntry;
238
308
  if (sqlverb && sqlverb === 'exec') {
239
309
  logger.info(`exec method skipped: ${resource}.${method}`);
@@ -247,11 +317,14 @@ export async function generate(options) {
247
317
  }
248
318
  }
249
319
 
320
+ // Sort operations by specificity before injecting into spec
321
+ const sortedResources = sortOperationsBySpecificity(resources, spec);
322
+
250
323
  // Inject into spec
251
324
  if (!spec.components) {
252
325
  spec.components = {};
253
326
  }
254
- spec.components['x-stackQL-resources'] = resources;
327
+ spec.components['x-stackQL-resources'] = sortedResources;
255
328
 
256
329
  // Inject servers if provided
257
330
  if (servers) {
@@ -11,7 +11,6 @@ import {
11
11
 
12
12
  // Constants
13
13
  const OPERATIONS = ["get", "post", "put", "delete", "patch", "options", "head", "trace"];
14
- const NON_OPERATIONS = ["parameters", "servers", "summary", "description"];
15
14
  const COMPONENTS_CHILDREN = ["schemas", "responses", "parameters", "examples", "requestBodies", "headers", "securitySchemes", "links", "callbacks"];
16
15
 
17
16
  /**
@@ -42,9 +41,10 @@ function isOperationExcluded(exclude, opItem) {
42
41
  * @param {string} svcDiscriminator - Service discriminator
43
42
  * @param {Object[]} allTags - All tags from API doc
44
43
  * @param {boolean} debug - Debug flag
44
+ * @param {Object} svcNameOverrides - Service name overrides
45
45
  * @returns {[string, string]} - [service name, service description]
46
46
  */
47
- function retServiceNameAndDesc(providerName, opItem, pathKey, svcDiscriminator, allTags, debug) {
47
+ function retServiceNameAndDesc(providerName, opItem, pathKey, svcDiscriminator, allTags, debug, svcNameOverrides) {
48
48
  let service = "default";
49
49
  let serviceDesc = `${providerName} API`;
50
50
 
@@ -84,9 +84,25 @@ function retServiceNameAndDesc(providerName, opItem, pathKey, svcDiscriminator,
84
84
  return ["skip", ""];
85
85
  }
86
86
 
87
+ // Apply service name overrides if present
88
+ if (svcNameOverrides && svcNameOverrides[service]) {
89
+ const newName = svcNameOverrides[service];
90
+ if (debug) {
91
+ logger.debug(`Overriding service name: ${service} -> ${newName}`);
92
+ }
93
+
94
+ // Update service description for path-based services
95
+ if (svcDiscriminator === "path") {
96
+ serviceDesc = `${providerName} ${newName} API`;
97
+ }
98
+
99
+ service = newName;
100
+ }
101
+
87
102
  return [service, serviceDesc];
88
103
  }
89
104
 
105
+
90
106
  /**
91
107
  * Initialize service map
92
108
  * @param {Object} services - Services map
@@ -192,43 +208,6 @@ function getPathLevelRefs(pathItem) {
192
208
  return refs;
193
209
  }
194
210
 
195
- /**
196
- * Add referenced components to service
197
- * @param {Set<string>} refs - Set of refs
198
- * @param {Object} service - Service object
199
- * @param {Object} components - Components from API doc
200
- * @param {boolean} debug - Debug flag
201
- */
202
- function addRefsToComponents(refs, service, components, debug) {
203
- for (const ref of refs) {
204
- const parts = ref.split('/');
205
-
206
- // Only process refs that point to components
207
- if (parts.length >= 4 && parts[1] === "components") {
208
- const componentType = parts[2];
209
- const componentName = parts[3];
210
-
211
- // Check if component type exists in service
212
- if (!service.components[componentType]) {
213
- service.components[componentType] = {};
214
- }
215
-
216
- // Skip if component already added
217
- if (service.components[componentType][componentName]) {
218
- continue;
219
- }
220
-
221
- // Add component if it exists in source document
222
- if (components[componentType] && components[componentType][componentName]) {
223
- service.components[componentType][componentName] = components[componentType][componentName];
224
- if (debug) {
225
- logger.debug(`Added component ${componentType}/${componentName}`);
226
- }
227
- }
228
- }
229
- }
230
- }
231
-
232
211
  /**
233
212
  * Add missing type: object to schema objects
234
213
  * @param {any} obj - Object to process
@@ -338,7 +317,8 @@ export async function split(options) {
338
317
  svcDiscriminator = "tag",
339
318
  exclude = null,
340
319
  overwrite = true,
341
- verbose = false
320
+ verbose = false,
321
+ svcNameOverrides = {} // Add this new parameter with default empty object
342
322
  } = options;
343
323
 
344
324
  // Setup logging based on verbosity
@@ -351,6 +331,13 @@ export async function split(options) {
351
331
  logger.info(`Output: ${outputDir}`);
352
332
  logger.info(`Service Discriminator: ${svcDiscriminator}`);
353
333
 
334
+ if (Object.keys(svcNameOverrides).length > 0) {
335
+ logger.info(`Using ${Object.keys(svcNameOverrides).length} service name overrides`);
336
+ if (verbose) {
337
+ logger.debug(`Service name overrides: ${JSON.stringify(svcNameOverrides, null, 2)}`);
338
+ }
339
+ }
340
+
354
341
  // Process exclude list
355
342
  const excludeList = exclude ? exclude.split(",") : [];
356
343
 
@@ -409,12 +396,11 @@ export async function split(options) {
409
396
  continue;
410
397
  }
411
398
 
412
- // Determine service name
413
399
  const [service, serviceDesc] = retServiceNameAndDesc(
414
400
  providerName, opItem, pathKey, svcDiscriminator,
415
- apiDocObj.tags || [], verbose
416
- );
417
-
401
+ apiDocObj.tags || [], verbose, svcNameOverrides
402
+ );
403
+
418
404
  // Skip if service is marked to skip
419
405
  if (service === 'skip') {
420
406
  logger.warn(`⭐️ Skipping service: ${service}`);