capman 0.5.5 → 0.6.1
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/CHANGELOG.md +1 -1
- package/bin/lib/cmd-generate.js +156 -12
- package/bin/lib/cmd-help.js +3 -0
- package/dist/cjs/cache.d.ts +9 -0
- package/dist/cjs/cache.d.ts.map +1 -1
- package/dist/cjs/cache.js +37 -7
- package/dist/cjs/cache.js.map +1 -1
- package/dist/cjs/engine.d.ts +68 -1
- package/dist/cjs/engine.d.ts.map +1 -1
- package/dist/cjs/engine.js +313 -13
- package/dist/cjs/engine.js.map +1 -1
- package/dist/cjs/generator.d.ts.map +1 -1
- package/dist/cjs/generator.js +28 -6
- package/dist/cjs/generator.js.map +1 -1
- package/dist/cjs/index.d.ts +3 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/learning.d.ts +7 -0
- package/dist/cjs/learning.d.ts.map +1 -1
- package/dist/cjs/learning.js +44 -23
- package/dist/cjs/learning.js.map +1 -1
- package/dist/cjs/matcher.d.ts +92 -0
- package/dist/cjs/matcher.d.ts.map +1 -1
- package/dist/cjs/matcher.js +354 -35
- package/dist/cjs/matcher.js.map +1 -1
- package/dist/cjs/parser.js +27 -9
- package/dist/cjs/parser.js.map +1 -1
- package/dist/cjs/resolver.d.ts +2 -2
- package/dist/cjs/resolver.d.ts.map +1 -1
- package/dist/cjs/resolver.js +66 -26
- package/dist/cjs/resolver.js.map +1 -1
- package/dist/cjs/schema.d.ts +865 -94
- package/dist/cjs/schema.d.ts.map +1 -1
- package/dist/cjs/schema.js +62 -12
- package/dist/cjs/schema.js.map +1 -1
- package/dist/cjs/types.d.ts +153 -9
- package/dist/cjs/types.d.ts.map +1 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/cache.d.ts +9 -0
- package/dist/esm/cache.js +37 -7
- package/dist/esm/engine.d.ts +68 -1
- package/dist/esm/engine.js +314 -14
- package/dist/esm/generator.js +28 -6
- package/dist/esm/index.d.ts +3 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/learning.d.ts +7 -0
- package/dist/esm/learning.js +45 -24
- package/dist/esm/matcher.d.ts +92 -0
- package/dist/esm/matcher.js +346 -35
- package/dist/esm/parser.js +27 -9
- package/dist/esm/resolver.d.ts +2 -2
- package/dist/esm/resolver.js +66 -26
- package/dist/esm/schema.d.ts +865 -94
- package/dist/esm/schema.js +62 -12
- package/dist/esm/types.d.ts +153 -9
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/esm/parser.js
CHANGED
|
@@ -31,7 +31,16 @@ async function loadSpec(source) {
|
|
|
31
31
|
return parseSpecText(text, source);
|
|
32
32
|
}
|
|
33
33
|
// Local file
|
|
34
|
-
const
|
|
34
|
+
const cwd = process.cwd();
|
|
35
|
+
const resolved = path.resolve(cwd, source);
|
|
36
|
+
// Guard against path traversal — same check used by FileCache and FileLearningStore.
|
|
37
|
+
// Prevents parseOpenAPI('../../etc/passwd') from reading arbitrary files when
|
|
38
|
+
// the source argument comes from user input (CLI args, UI, CI scripts).
|
|
39
|
+
const allowedPrefix = cwd === '/' ? '/' : cwd + path.sep;
|
|
40
|
+
if (!resolved.startsWith(allowedPrefix)) {
|
|
41
|
+
throw new Error(`Spec path "${source}" resolves outside the working directory.\n` +
|
|
42
|
+
`Resolved: ${resolved}\nAllowed: ${cwd}`);
|
|
43
|
+
}
|
|
35
44
|
if (!fs.existsSync(resolved)) {
|
|
36
45
|
throw new Error(`Spec file not found: ${resolved}`);
|
|
37
46
|
}
|
|
@@ -101,11 +110,20 @@ function convertSpec(spec) {
|
|
|
101
110
|
warnings.push(`Skipped ${method} ${urlPath} — no useful info to generate capability`);
|
|
102
111
|
continue;
|
|
103
112
|
}
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
113
|
+
// De-conflict duplicate IDs — loop until the candidate ID is unique.
|
|
114
|
+
// A single find() check is insufficient: if two operations both produce
|
|
115
|
+
// `get_user`, the second becomes `get_user_get`. A third `get_user` would
|
|
116
|
+
// then collide with `get_user_get` only when it also uses GET — the general
|
|
117
|
+
// multi-collision case is only caught by looping.
|
|
118
|
+
let candidateId = result.id;
|
|
119
|
+
let dedupeCount = 0;
|
|
120
|
+
while (capabilities.find(c => c.id === candidateId)) {
|
|
121
|
+
dedupeCount++;
|
|
122
|
+
candidateId = `${result.id}_${method.toLowerCase()}${dedupeCount > 1 ? `_${dedupeCount}` : ''}`;
|
|
123
|
+
}
|
|
124
|
+
if (candidateId !== result.id) {
|
|
125
|
+
warnings.push(`Duplicate ID resolved: ${result.id} → ${candidateId}`);
|
|
126
|
+
result.id = candidateId;
|
|
109
127
|
}
|
|
110
128
|
capabilities.push(result);
|
|
111
129
|
}
|
|
@@ -259,9 +277,9 @@ function extractBaseUrl(spec) {
|
|
|
259
277
|
const base = spec.basePath ?? '';
|
|
260
278
|
return `${scheme}://${spec.host}${base}`.replace(/\/$/, '');
|
|
261
279
|
}
|
|
262
|
-
|
|
263
|
-
`
|
|
264
|
-
|
|
280
|
+
throw new Error(`No server URL found in OpenAPI spec — cannot determine base URL.\n` +
|
|
281
|
+
`Add a "servers" entry (OpenAPI 3.x) or "host" + "basePath" (Swagger 2.x), ` +
|
|
282
|
+
`or set baseUrl manually in capman.config.js after generating.`);
|
|
265
283
|
}
|
|
266
284
|
function sanitizeAppName(title) {
|
|
267
285
|
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
package/dist/esm/resolver.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MatchResult, ResolveResult } from './types';
|
|
1
|
+
import type { MatchResult, ResolveResult, Capability } from './types';
|
|
2
2
|
export interface AuthContext {
|
|
3
3
|
/** Whether the current request is authenticated */
|
|
4
4
|
isAuthenticated: boolean;
|
|
@@ -25,5 +25,5 @@ export interface ResolveOptions {
|
|
|
25
25
|
*/
|
|
26
26
|
retryAllMethods?: boolean;
|
|
27
27
|
}
|
|
28
|
-
export declare function checkPrivacy(capability:
|
|
28
|
+
export declare function checkPrivacy(capability: Capability, auth?: AuthContext): string | null;
|
|
29
29
|
export declare function resolve(matchResult: MatchResult, params?: Record<string, unknown>, options?: ResolveOptions): Promise<ResolveResult>;
|
package/dist/esm/resolver.js
CHANGED
|
@@ -68,13 +68,13 @@ export async function resolve(matchResult, params = {}, options = {}) {
|
|
|
68
68
|
.map(p => p.name));
|
|
69
69
|
switch (resolver.type) {
|
|
70
70
|
case 'api':
|
|
71
|
-
return await resolveApi(resolver, enrichedParams, options, sessionParamNames);
|
|
71
|
+
return await resolveApi(resolver, enrichedParams, options, sessionParamNames, capability.errors ?? []);
|
|
72
72
|
case 'nav':
|
|
73
73
|
return resolveNav(resolver, enrichedParams);
|
|
74
74
|
case 'hybrid': {
|
|
75
75
|
logger.debug('Hybrid resolver — running API and nav in parallel');
|
|
76
76
|
const [apiResult, navResult] = await Promise.all([
|
|
77
|
-
resolveApi(resolver.api, enrichedParams, options, sessionParamNames),
|
|
77
|
+
resolveApi(resolver.api, enrichedParams, options, sessionParamNames, capability.errors ?? []),
|
|
78
78
|
Promise.resolve(resolveNav(resolver.nav, enrichedParams)),
|
|
79
79
|
]);
|
|
80
80
|
return {
|
|
@@ -116,23 +116,27 @@ export async function resolve(matchResult, params = {}, options = {}) {
|
|
|
116
116
|
* Full partial success reporting (partialSuccess, completedCalls, failedCalls)
|
|
117
117
|
* is planned for a future version.
|
|
118
118
|
*/
|
|
119
|
-
async function resolveApi(resolver, params, options, sessionParamNames = new Set()) {
|
|
119
|
+
async function resolveApi(resolver, params, options, sessionParamNames = new Set(), capabilityErrors = []) {
|
|
120
120
|
const startTime = Date.now();
|
|
121
121
|
const retries = options.retries ?? 0;
|
|
122
122
|
const timeoutMs = options.timeoutMs ?? 5000;
|
|
123
|
+
// Map url → endpoint metadata for idempotency and Idempotency-Key injection
|
|
124
|
+
const endpointMeta = new Map();
|
|
123
125
|
const apiCalls = resolver.endpoints.map(endpoint => {
|
|
124
|
-
// Build per-endpoint params — only inject session params if this
|
|
125
|
-
// specific endpoint has the placeholder. Prevents userId leaking
|
|
126
|
-
// as ?user_id=xyz on endpoints that don't use it in their path.
|
|
127
126
|
const endpointParams = { ...params };
|
|
128
127
|
for (const name of sessionParamNames) {
|
|
129
128
|
if (!endpoint.path.includes(`{${name}}`)) {
|
|
130
|
-
delete endpointParams[name];
|
|
129
|
+
delete endpointParams[name];
|
|
131
130
|
}
|
|
132
131
|
}
|
|
132
|
+
const url = buildUrl(options.baseUrl ?? '', endpoint.path, endpointParams, sessionParamNames);
|
|
133
|
+
endpointMeta.set(url, {
|
|
134
|
+
idempotent: endpoint.idempotent,
|
|
135
|
+
idempotencyKey: endpoint.idempotencyKey,
|
|
136
|
+
});
|
|
133
137
|
return {
|
|
134
138
|
method: endpoint.method,
|
|
135
|
-
url
|
|
139
|
+
url,
|
|
136
140
|
params: Object.fromEntries(Object.entries(endpointParams).filter(([, v]) => v !== null && v !== undefined)),
|
|
137
141
|
};
|
|
138
142
|
});
|
|
@@ -151,23 +155,42 @@ async function resolveApi(resolver, params, options, sessionParamNames = new Set
|
|
|
151
155
|
// Only retry safe/idempotent methods — retrying POST/PUT/PATCH/DELETE
|
|
152
156
|
// can cause duplicate side effects (e.g. duplicate orders, double charges).
|
|
153
157
|
async function fetchWithRetry(call) {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
const meta = endpointMeta.get(call.url);
|
|
159
|
+
// Explicit idempotent flag overrides method-based default
|
|
160
|
+
const isIdempotent = meta?.idempotent !== undefined
|
|
161
|
+
? meta.idempotent
|
|
162
|
+
: SAFE_METHODS.has(call.method);
|
|
163
|
+
const effectiveRetries = (options.retryAllMethods || isIdempotent) ? retries : 0;
|
|
164
|
+
let lastErr = new Error('fetchWithRetry: exhausted all attempts without result');
|
|
158
165
|
for (let attempt = 0; attempt <= effectiveRetries; attempt++) {
|
|
159
166
|
const controller = new AbortController();
|
|
160
167
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
161
168
|
try {
|
|
169
|
+
// Inject Idempotency-Key header when configured
|
|
170
|
+
const idempotencyHeaders = {};
|
|
171
|
+
if (meta?.idempotencyKey) {
|
|
172
|
+
const keyValue = call.params[meta.idempotencyKey];
|
|
173
|
+
if (keyValue !== null && keyValue !== undefined) {
|
|
174
|
+
idempotencyHeaders['Idempotency-Key'] = String(keyValue);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
162
177
|
const res = await fetchFn(call.url, {
|
|
163
178
|
method: call.method,
|
|
164
|
-
headers: options.headers ?? {},
|
|
179
|
+
headers: { ...options.headers ?? {}, ...idempotencyHeaders },
|
|
165
180
|
signal: controller.signal,
|
|
166
181
|
body: ['POST', 'PUT', 'PATCH'].includes(call.method)
|
|
167
182
|
? JSON.stringify(Object.fromEntries(Object.entries(call.params).filter(([, v]) => v !== null && v !== undefined)))
|
|
168
183
|
: undefined,
|
|
169
184
|
});
|
|
170
185
|
clearTimeout(timer);
|
|
186
|
+
// Throw on retryable 5xx — fetch() resolves (doesn't throw) on HTTP errors,
|
|
187
|
+
// so without this check a 503 is returned immediately with no retry.
|
|
188
|
+
// 4xx errors are not retried — they are client errors that won't change.
|
|
189
|
+
if (res.status >= 500 && attempt < effectiveRetries) {
|
|
190
|
+
lastErr = new Error(`HTTP ${res.status}`);
|
|
191
|
+
logger.warn(`Server error ${res.status} (attempt ${attempt + 1}/${effectiveRetries + 1}) — retrying`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
171
194
|
return res;
|
|
172
195
|
}
|
|
173
196
|
catch (err) {
|
|
@@ -184,32 +207,49 @@ async function resolveApi(resolver, params, options, sessionParamNames = new Set
|
|
|
184
207
|
}
|
|
185
208
|
throw lastErr;
|
|
186
209
|
}
|
|
210
|
+
let enrichedCalls = apiCalls.map(c => ({ ...c }));
|
|
187
211
|
try {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
212
|
+
const settled = await Promise.allSettled(apiCalls.map(c => fetchWithRetry(c)));
|
|
213
|
+
enrichedCalls = await Promise.all(settled.map(async (result, i) => {
|
|
214
|
+
if (result.status === 'rejected') {
|
|
215
|
+
const reason = result.reason;
|
|
216
|
+
logger.warn(`Endpoint ${apiCalls[i].method} ${apiCalls[i].url} failed: ${reason}`);
|
|
217
|
+
return {
|
|
218
|
+
...apiCalls[i],
|
|
219
|
+
status: 0,
|
|
220
|
+
error: reason instanceof Error ? reason.message : String(reason),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const res = result.value;
|
|
199
224
|
let data = undefined;
|
|
200
225
|
try {
|
|
201
226
|
const text = await res.text();
|
|
202
227
|
data = text ? JSON.parse(text) : undefined;
|
|
203
228
|
}
|
|
204
|
-
catch { /* non-JSON response */ }
|
|
229
|
+
catch { /* non-JSON response body */ }
|
|
205
230
|
return { ...apiCalls[i], status: res.status, data };
|
|
206
231
|
}));
|
|
232
|
+
const failedCall = enrichedCalls.find(c => typeof c.status === 'number' && (c.status === 0 || c.status >= 400));
|
|
233
|
+
if (failedCall) {
|
|
234
|
+
const matchedError = capabilityErrors.find(e => e.httpStatus === failedCall.status);
|
|
235
|
+
const statusLabel = failedCall.status === 0 ? 'network failure' : String(failedCall.status);
|
|
236
|
+
return {
|
|
237
|
+
success: false,
|
|
238
|
+
resolverType: 'api',
|
|
239
|
+
apiCalls: enrichedCalls,
|
|
240
|
+
durationMs: Date.now() - startTime,
|
|
241
|
+
error: matchedError
|
|
242
|
+
? `${matchedError.code}: ${matchedError.description}`
|
|
243
|
+
: `API request failed: ${statusLabel} on ${failedCall.method} ${failedCall.url}`,
|
|
244
|
+
matchedError,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
207
247
|
logger.debug(`API calls completed in ${Date.now() - startTime}ms`);
|
|
208
248
|
return { success: true, resolverType: 'api', apiCalls: enrichedCalls, durationMs: Date.now() - startTime };
|
|
209
249
|
}
|
|
210
250
|
catch (err) {
|
|
211
251
|
return {
|
|
212
|
-
success: false, resolverType: 'api', apiCalls,
|
|
252
|
+
success: false, resolverType: 'api', apiCalls: enrichedCalls,
|
|
213
253
|
durationMs: Date.now() - startTime,
|
|
214
254
|
error: err instanceof Error ? err.message : String(err),
|
|
215
255
|
};
|