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.
Files changed (60) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/bin/lib/cmd-generate.js +156 -12
  3. package/bin/lib/cmd-help.js +3 -0
  4. package/dist/cjs/cache.d.ts +9 -0
  5. package/dist/cjs/cache.d.ts.map +1 -1
  6. package/dist/cjs/cache.js +37 -7
  7. package/dist/cjs/cache.js.map +1 -1
  8. package/dist/cjs/engine.d.ts +68 -1
  9. package/dist/cjs/engine.d.ts.map +1 -1
  10. package/dist/cjs/engine.js +313 -13
  11. package/dist/cjs/engine.js.map +1 -1
  12. package/dist/cjs/generator.d.ts.map +1 -1
  13. package/dist/cjs/generator.js +28 -6
  14. package/dist/cjs/generator.js.map +1 -1
  15. package/dist/cjs/index.d.ts +3 -1
  16. package/dist/cjs/index.d.ts.map +1 -1
  17. package/dist/cjs/index.js +5 -1
  18. package/dist/cjs/index.js.map +1 -1
  19. package/dist/cjs/learning.d.ts +7 -0
  20. package/dist/cjs/learning.d.ts.map +1 -1
  21. package/dist/cjs/learning.js +44 -23
  22. package/dist/cjs/learning.js.map +1 -1
  23. package/dist/cjs/matcher.d.ts +92 -0
  24. package/dist/cjs/matcher.d.ts.map +1 -1
  25. package/dist/cjs/matcher.js +354 -35
  26. package/dist/cjs/matcher.js.map +1 -1
  27. package/dist/cjs/parser.js +27 -9
  28. package/dist/cjs/parser.js.map +1 -1
  29. package/dist/cjs/resolver.d.ts +2 -2
  30. package/dist/cjs/resolver.d.ts.map +1 -1
  31. package/dist/cjs/resolver.js +66 -26
  32. package/dist/cjs/resolver.js.map +1 -1
  33. package/dist/cjs/schema.d.ts +865 -94
  34. package/dist/cjs/schema.d.ts.map +1 -1
  35. package/dist/cjs/schema.js +62 -12
  36. package/dist/cjs/schema.js.map +1 -1
  37. package/dist/cjs/types.d.ts +153 -9
  38. package/dist/cjs/types.d.ts.map +1 -1
  39. package/dist/cjs/version.d.ts +1 -1
  40. package/dist/cjs/version.js +1 -1
  41. package/dist/esm/cache.d.ts +9 -0
  42. package/dist/esm/cache.js +37 -7
  43. package/dist/esm/engine.d.ts +68 -1
  44. package/dist/esm/engine.js +314 -14
  45. package/dist/esm/generator.js +28 -6
  46. package/dist/esm/index.d.ts +3 -1
  47. package/dist/esm/index.js +2 -0
  48. package/dist/esm/learning.d.ts +7 -0
  49. package/dist/esm/learning.js +45 -24
  50. package/dist/esm/matcher.d.ts +92 -0
  51. package/dist/esm/matcher.js +346 -35
  52. package/dist/esm/parser.js +27 -9
  53. package/dist/esm/resolver.d.ts +2 -2
  54. package/dist/esm/resolver.js +66 -26
  55. package/dist/esm/schema.d.ts +865 -94
  56. package/dist/esm/schema.js +62 -12
  57. package/dist/esm/types.d.ts +153 -9
  58. package/dist/esm/version.d.ts +1 -1
  59. package/dist/esm/version.js +1 -1
  60. package/package.json +1 -1
@@ -31,7 +31,16 @@ async function loadSpec(source) {
31
31
  return parseSpecText(text, source);
32
32
  }
33
33
  // Local file
34
- const resolved = path.resolve(process.cwd(), source);
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
- // Check for duplicate IDs
105
- const existing = capabilities.find(c => c.id === result.id);
106
- if (existing) {
107
- result.id = `${result.id}_${method.toLowerCase()}`;
108
- warnings.push(`Duplicate ID resolved: ${result.id}`);
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
- logger.warn(`No server URL found in spec — using placeholder "https://api.your-app.com". ` +
263
- `Set baseUrl manually in the generated config before use.`);
264
- return 'https://api.your-app.com';
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, '');
@@ -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: import('./types').Capability, auth?: AuthContext): string | null;
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>;
@@ -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]; // strip session param — not in this endpoint's path
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: buildUrl(options.baseUrl ?? '', endpoint.path, endpointParams, sessionParamNames),
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 effectiveRetries = (options.retryAllMethods || SAFE_METHODS.has(call.method))
155
- ? retries
156
- : 0;
157
- let lastErr;
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 responses = await Promise.all(apiCalls.map(c => fetchWithRetry(c)));
189
- const failedIdx = responses.findIndex(r => !r.ok);
190
- if (failedIdx !== -1) {
191
- const failed = responses[failedIdx];
192
- return {
193
- success: false, resolverType: 'api', apiCalls,
194
- durationMs: Date.now() - startTime,
195
- error: `API request failed: ${failed.status} ${failed.statusText}`,
196
- };
197
- }
198
- const enrichedCalls = await Promise.all(responses.map(async (res, i) => {
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
  };