@stackql/provider-utils 0.4.2 → 0.4.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackql/provider-utils",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Utilities for building StackQL providers from OpenAPI specifications.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -23,18 +23,44 @@ export function getIndefiniteArticle(resourceName) {
23
23
  return article;
24
24
  }
25
25
 
26
+ /**
27
+ * Sanitizes HTML with special handling for allowed tags and backticked content
28
+ * @param {string} text - The text to sanitize
29
+ * @return {string} - The sanitized text
30
+ */
26
31
  export function sanitizeHtml(text) {
27
- return text
32
+ if (!text) return '';
33
+
34
+ // First apply the general sanitization
35
+ let result = text
28
36
  .replace(/{/g, '{')
29
37
  .replace(/}/g, '}')
30
38
  .replace(/>/g, '>')
31
39
  .replace(/</g, '&lt;')
32
40
  // edge case
33
41
  .replace(/&#125;_&#123;/g, '&#125;&#95;&#123;')
34
- .replace(/\n/g, '<br />')
42
+ .replace(/\n/g, '<br />');
43
+
44
+ // Fix 1: Replace &lt;br&gt;, &lt;br/&gt;, &lt;p&gt;, &lt;/p&gt; back to their literal HTML tags
45
+ // Make sure <br> is always self-closing for MDX compatibility
46
+ result = result
47
+ .replace(/&lt;br\s*\/?&gt;/gi, '<br />')
48
+ .replace(/&lt;p&gt;/gi, '<p>')
49
+ .replace(/&lt;\/p&gt;/gi, '</p>');
50
+
51
+ // Fix 2: Find any &lt; or &gt; inside backticks and convert them back to < and >
52
+ // We need to handle the backtick content by finding pairs of backticks
53
+ result = result.replace(/`([^`]*)`/g, (match, content) => {
54
+ // Convert &lt; and &gt; back to < and > only within backticked content
55
+ const fixedContent = content
56
+ .replace(/&lt;/g, '<')
57
+ .replace(/&gt;/g, '>');
58
+ return '`' + fixedContent + '`';
59
+ });
60
+
61
+ return result;
35
62
  }
36
63
 
37
-
38
64
  export function getSqlMethodsWithOrderedFields(resourceData, dereferencedAPI, sqlVerb) {
39
65
  const methods = {};
40
66
 
@@ -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,7 +197,8 @@ 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
 
@@ -150,7 +257,8 @@ export async function analyze(options) {
150
257
  existingMappings[key] = {
151
258
  resourceName: row.stackql_resource_name || '',
152
259
  methodName: row.stackql_method_name || '',
153
- sqlVerb: row.stackql_verb || ''
260
+ sqlVerb: row.stackql_verb || '',
261
+ objectKey: row.stackql_object_key || ''
154
262
  };
155
263
  }
156
264
  })
@@ -176,7 +284,7 @@ export async function analyze(options) {
176
284
 
177
285
  // Only write header if creating a new file
178
286
  if (!fileExists) {
179
- writer.write('filename,path,operationId,formatted_op_id,verb,response_object,tags,formatted_tags,stackql_resource_name,stackql_method_name,stackql_verb,op_description\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');
180
288
  }
181
289
 
182
290
  const files = fs.readdirSync(inputDir);
@@ -231,7 +339,22 @@ export async function analyze(options) {
231
339
  const pathRef = `#/paths/${encodedPath}/${verb}`;
232
340
 
233
341
  // Find existing mapping if available
234
- 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
+ }
235
358
 
236
359
  // Get operation description
237
360
  const opDescription = operation.summary || operation.description || '';
@@ -249,11 +372,12 @@ export async function analyze(options) {
249
372
  resourceName: escapeCsvField(resourceName),
250
373
  methodName: escapeCsvField(methodName),
251
374
  sqlVerb: escapeCsvField(sqlVerb),
375
+ objectKey: escapeCsvField(objectKey),
252
376
  opDescription: escapeCsvField(opDescription)
253
377
  };
254
378
 
255
379
  // Write row
256
- 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.opDescription}\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`);
257
381
  }
258
382
  }
259
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) {