wrekenfile-converter
Version:
Convert OpenAPI and Postman specs into Wrekenfiles, with chunking for vector database storage
318 lines • 11.2 kB
JavaScript
;
/**
* Deterministic canonical method ID generation.
* Format: <namespace>.<resource>.<action>
* No HTTP method or path params in name; semantic only.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.computeCanonicalId = computeCanonicalId;
exports.resolveCanonicalIds = resolveCanonicalIds;
/** Known action verbs (fixed dictionary) */
const STANDARD_VERBS = new Set([
'list',
'get',
'create',
'update',
'delete',
'install',
'remove',
'execute',
'retry',
'initialize',
'upload',
'download',
'connect',
'invite',
'restart',
'generate',
'refresh',
'check',
'validate',
'migrate',
'cancel',
'refund',
'suspend',
'approve',
'reject',
'start',
'stop',
'pause',
'resume',
]);
/** Verb-first form for compound actions (kebab segment → camelCase with verb first) */
/** Irregular plural → singular */
const IRREGULAR_SINGULAR = {
policies: 'policy',
countries: 'country',
categories: 'category',
buckets: 'bucket',
clusters: 'cluster',
accounts: 'account',
continents: 'continent',
identities: 'identity',
utilities: 'utility',
};
/**
* Normalize path: strip leading slash and version prefix only.
* First segment = namespace (api, admin, partner, etc.); do not strip it.
*/
function normalizePath(path) {
let p = path.replace(/^\/+/, '').trim();
// Strip version prefix: /v1/, /v2/, /v3/
p = p.replace(/^v\d+\//i, '');
p = p.replace(/\.json$/i, '');
p = p.replace(/\.xml$/i, '');
return p.replace(/\/+$/, '');
}
/**
* Split path into segments and remove path-parameter segments ({id}, {foo}, :id, etc).
*/
function pathSegmentsWithoutParams(path) {
const segments = path.split('/').filter(Boolean);
return segments.filter((s) => {
// Remove {param} style
if (s.startsWith('{') && s.endsWith('}'))
return false;
// Remove :param style
if (s.startsWith(':'))
return false;
return true;
});
}
/**
* Singularize a resource name (simple rules + irregular map).
*/
function singularize(word) {
const lower = word.toLowerCase();
if (IRREGULAR_SINGULAR[lower])
return IRREGULAR_SINGULAR[lower];
if (lower.endsWith('ies') && lower.length > 4)
return lower.slice(0, -3) + 'y';
if (lower.endsWith('ses') || lower.endsWith('xes') || lower.endsWith('zes'))
return lower.slice(0, -2);
if (lower.endsWith('s') && !lower.endsWith('ss') && lower.length > 1)
return lower.slice(0, -1);
return lower;
}
/**
* Convert kebab-case or snake_case to camelCase.
*/
function toCamelCase(segment) {
return segment
.replace(/[-_]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ''))
.replace(/^(.)/, (_, c) => c.toLowerCase());
}
/**
* For an action-related segment:
* - If it ends with "-<verb>", move verb to front: "helm-release-remove" -> "removeHelmRelease"
* - If it's a single noun (e.g. "shell"), produce "executeShell"
* - Otherwise, just kebab/snake -> camelCase
*/
/**
* Extract the primary verb for a method from remaining path segments and HTTP method.
* SINGLE VERB RULE: only one verb is allowed; we keep the first one we find.
*/
function extractPrimaryVerb(remaining, httpMethod, hasIdInPath) {
const foundVerbs = [];
for (const segment of remaining) {
const lower = segment.toLowerCase();
// Segment exactly matches a verb
if (STANDARD_VERBS.has(lower)) {
foundVerbs.push(lower);
continue;
}
// Segment ends with -verb (e.g. "helm-release-remove")
for (const verb of STANDARD_VERBS) {
if (lower.endsWith('-' + verb)) {
foundVerbs.push(verb);
break;
}
}
}
if (foundVerbs.length > 0) {
// SINGLE VERB RULE: first verb only, ignore all others
return foundVerbs[0];
}
// Fallback: derive verb from HTTP method
switch ((httpMethod || 'GET').toUpperCase()) {
case 'GET':
return hasIdInPath ? 'get' : 'list';
case 'POST':
return 'create';
case 'PUT':
case 'PATCH':
return 'update';
case 'DELETE':
return 'delete';
default:
return 'execute';
}
}
/**
* Extract at most ONE subresource from remaining segments, based on nouns before the verb.
* We pick the last meaningful noun before the verb (most specific).
*/
function extractSubresource(remaining, _verb) {
if (remaining.length === 0)
return null;
// Find index of the verb in remaining segments (if present)
const verbIndex = remaining.findIndex((s) => {
const lower = s.toLowerCase();
if (STANDARD_VERBS.has(lower))
return true;
const parts = lower.split('-');
return parts.length > 1 && STANDARD_VERBS.has(parts[parts.length - 1] || '');
});
const beforeVerb = verbIndex >= 0 ? remaining.slice(0, verbIndex) : remaining;
if (beforeVerb.length === 0)
return null;
// Walk backwards to find last noun-like segment (not a verb)
for (let i = beforeVerb.length - 1; i >= 0; i--) {
const segment = beforeVerb[i];
const lower = segment.toLowerCase();
if (STANDARD_VERBS.has(lower))
continue;
return toCamelCase(segment);
}
return null;
}
/**
* Compute base canonical ID from HTTP method and path. Deterministic; no collision handling.
* Enforces grammar: <namespace>.<resource>[.<subresource>].<action>
*/
function computeCanonicalId(libraryName, httpMethod, path) {
const normalized = normalizePath(path);
const segments = pathSegmentsWithoutParams(normalized);
const namespace = libraryName.replace(/[^a-zA-Z0-9]/g, '').toLowerCase() || 'api';
if (segments.length === 0) {
return `${namespace}.resource.execute`;
}
const method = (httpMethod || 'GET').toUpperCase();
// primary resource
const resource = singularize(segments[0] || 'resource');
const remaining = segments.slice(1);
const hasIdInPath = /\/\{[^}]+\}(\/|$)/.test(path) || /\/:[^/]+(\/|$)/.test(path);
// SINGLE VERB RULE: extract one primary verb
const verb = extractPrimaryVerb(remaining, method, hasIdInPath);
// Optional subresource (at most one)
const subresource = extractSubresource(remaining, verb);
const parts = [namespace, resource];
if (subresource) {
parts.push(subresource);
}
parts.push(verb);
// MAX 4 SEGMENTS RULE: if exceeded, collapse to namespace.resource.verb
if (parts.length > 4) {
return [parts[0], parts[1], parts[parts.length - 1]].join('.');
}
return parts.join('.');
}
/**
* Resolve canonical IDs for all methods with collision handling:
* 1. Use existing CANONICAL_ID if provided.
* 2. Else compute base ID from HTTP + path.
* 3. On collision: extend with next path segment (subresource).
* 4. If still colliding: append short deterministic hash.
*/
function resolveCanonicalIds(methods, libraryName) {
const result = new Map();
const used = new Map(); // canonicalId -> methodId (first claimant)
function tryAssign(methodId, canonicalId) {
const existing = used.get(canonicalId);
if (existing === undefined || existing === methodId) {
used.set(canonicalId, methodId);
return true;
}
return false;
}
// First pass: assign existing or base canonical IDs; collect collisions
const pending = [];
for (const m of methods) {
if (m.existingCanonicalId && /^[a-z0-9]+\.[a-z0-9]+\.[a-zA-Z0-9]+$/.test(m.existingCanonicalId)) {
const cid = m.existingCanonicalId;
if (tryAssign(m.methodId, cid)) {
result.set(m.methodId, cid);
}
else {
pending.push({
methodId: m.methodId,
httpMethod: m.httpMethod || 'GET',
endpoint: m.endpoint || '',
baseId: cid,
});
}
continue;
}
if (m.httpMethod && m.endpoint) {
const baseId = computeCanonicalId(libraryName, m.httpMethod, m.endpoint);
if (tryAssign(m.methodId, baseId)) {
result.set(m.methodId, baseId);
}
else {
pending.push({
methodId: m.methodId,
httpMethod: m.httpMethod,
endpoint: m.endpoint,
baseId,
});
}
}
else {
// No HTTP info (e.g. SDK-only): use methodId as basis, sanitized
const namespace = libraryName.replace(/[^a-zA-Z0-9]/g, '').toLowerCase() || 'sdk';
const fallback = m.methodId
.toLowerCase()
.replace(/^([a-z]+)--/, '$1.')
.replace(/--/g, '.')
.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
.replace(/[^a-zA-Z0-9.]/g, '');
const baseId = fallback || `${namespace}.method`;
let cid = baseId;
if (!tryAssign(m.methodId, cid)) {
pending.push({ methodId: m.methodId, httpMethod: m.httpMethod || 'GET', endpoint: m.endpoint || '', baseId });
}
else {
result.set(m.methodId, cid);
}
}
}
// Resolve pending collisions: use counter suffix for uniqueness (no hashes)
for (const p of pending) {
let finalCandidate = p.baseId;
let attempts = 1;
while (!tryAssign(p.methodId, finalCandidate) && attempts < 100) {
finalCandidate = `${p.baseId}.${attempts}`;
attempts++;
}
result.set(p.methodId, finalCandidate);
}
// Final validation: ensure no duplicates (safety check)
const seen = new Map(); // canonicalId -> first methodId that used it
const duplicates = new Map(); // methodId -> canonicalId (duplicate)
for (const [methodId, canonicalId] of result.entries()) {
const firstUser = seen.get(canonicalId);
if (firstUser !== undefined) {
// Duplicate found
duplicates.set(methodId, canonicalId);
}
else {
seen.set(canonicalId, methodId);
}
}
if (duplicates.size > 0) {
// Force uniqueness with a fallback counter if something incredibly went wrong
console.warn(`Warning: Found ${duplicates.size} duplicate canonical ID(s), forcing uniqueness`);
for (const [methodId, canonicalId] of duplicates.entries()) {
let uniqueId = canonicalId;
let counter = 1;
while (seen.has(uniqueId) && seen.get(uniqueId) !== methodId) {
uniqueId = `${canonicalId}.fallback${counter}`;
counter++;
}
seen.set(uniqueId, methodId);
result.set(methodId, uniqueId);
}
}
return result;
}
//# sourceMappingURL=canonical-id.js.map