@apitap/core 1.6.1 → 1.6.2
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/dist/capture/cdp-attach.js +36 -2
- package/dist/capture/cdp-attach.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/replay/engine.d.ts +1 -1
- package/dist/replay/engine.js +4 -1
- package/dist/replay/engine.js.map +1 -1
- package/dist/skill/generator.d.ts +18 -0
- package/dist/skill/generator.js +98 -24
- package/dist/skill/generator.js.map +1 -1
- package/dist/skill/merge.js +71 -0
- package/dist/skill/merge.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/src/capture/cdp-attach.ts +34 -2
- package/src/cli.ts +1 -1
- package/src/replay/engine.ts +7 -1
- package/src/skill/generator.ts +132 -25
- package/src/skill/merge.ts +72 -0
- package/src/types.ts +1 -1
package/src/skill/generator.ts
CHANGED
|
@@ -292,6 +292,7 @@ export class SkillGenerator {
|
|
|
292
292
|
private oauthClientSecret: string | undefined;
|
|
293
293
|
private oauthRefreshToken: string | undefined;
|
|
294
294
|
private totalNetworkBytes = 0; // v1.0: accumulate all response sizes
|
|
295
|
+
private _lastDedupKey: string | null = null; // side-channel for dedup key when _prepareEndpoint returns null
|
|
295
296
|
|
|
296
297
|
/** Number of unique endpoints captured so far */
|
|
297
298
|
get endpointCount(): number {
|
|
@@ -305,13 +306,37 @@ export class SkillGenerator {
|
|
|
305
306
|
};
|
|
306
307
|
}
|
|
307
308
|
|
|
308
|
-
/**
|
|
309
|
-
|
|
309
|
+
/**
|
|
310
|
+
* Extract shared request-side logic: URL parsing, GraphQL detection, path
|
|
311
|
+
* parameterization, dedup, OAuth, auth extraction, header/query scrubbing,
|
|
312
|
+
* and endpoint ID generation.
|
|
313
|
+
*
|
|
314
|
+
* Returns null when the endpoint is a duplicate (existing non-skeleton).
|
|
315
|
+
* When returning null, stores the dedup key in `_lastDedupKey` so callers
|
|
316
|
+
* can still perform body storage for cross-request diffing.
|
|
317
|
+
*/
|
|
318
|
+
private _prepareEndpoint(request: {
|
|
319
|
+
url: string;
|
|
320
|
+
method: string;
|
|
321
|
+
headers: Record<string, string>;
|
|
322
|
+
postData?: string;
|
|
323
|
+
}): {
|
|
324
|
+
key: string;
|
|
325
|
+
method: string;
|
|
326
|
+
paramPath: string;
|
|
327
|
+
queryParams: Record<string, { type: string; example: string }>;
|
|
328
|
+
safeHeaders: Record<string, string>;
|
|
329
|
+
exampleUrl: string;
|
|
330
|
+
endpointId: string;
|
|
331
|
+
graphqlInfo: { operationName: string; query: string; variables: Record<string, unknown> | null } | null;
|
|
332
|
+
entropyDetected: Set<string>;
|
|
333
|
+
isSkeletonReplacement: boolean;
|
|
334
|
+
} | null {
|
|
310
335
|
this.captureCount++;
|
|
311
336
|
|
|
312
|
-
const url = new URL(
|
|
313
|
-
const method =
|
|
314
|
-
const contentType =
|
|
337
|
+
const url = new URL(request.url);
|
|
338
|
+
const method = request.method;
|
|
339
|
+
const contentType = request.headers['content-type'] ?? '';
|
|
315
340
|
|
|
316
341
|
// Track baseUrl from the first captured exchange
|
|
317
342
|
if (!this.baseUrl) {
|
|
@@ -319,11 +344,11 @@ export class SkillGenerator {
|
|
|
319
344
|
}
|
|
320
345
|
|
|
321
346
|
// Check for GraphQL
|
|
322
|
-
const isGraphQL = isGraphQLEndpoint(url.pathname, contentType,
|
|
347
|
+
const isGraphQL = isGraphQLEndpoint(url.pathname, contentType, request.postData ?? null);
|
|
323
348
|
let graphqlInfo: { operationName: string; query: string; variables: Record<string, unknown> | null } | null = null;
|
|
324
349
|
|
|
325
|
-
if (isGraphQL &&
|
|
326
|
-
const parsed = parseGraphQLBody(
|
|
350
|
+
if (isGraphQL && request.postData) {
|
|
351
|
+
const parsed = parseGraphQLBody(request.postData);
|
|
327
352
|
if (parsed) {
|
|
328
353
|
const opName = extractOperationName(parsed.query, parsed.operationName);
|
|
329
354
|
graphqlInfo = {
|
|
@@ -344,20 +369,22 @@ export class SkillGenerator {
|
|
|
344
369
|
? `${method} graphql:${graphqlInfo.operationName}`
|
|
345
370
|
: `${method} ${dedupPath}`;
|
|
346
371
|
|
|
347
|
-
// Track response bytes for all exchanges (for browser cost measurement)
|
|
348
|
-
this.totalNetworkBytes += exchange.response.body.length;
|
|
349
|
-
|
|
350
372
|
if (this.endpoints.has(key)) {
|
|
351
|
-
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
|
|
373
|
+
const existing = this.endpoints.get(key)!;
|
|
374
|
+
if (existing.endpointProvenance === 'skeleton') {
|
|
375
|
+
// Skeleton endpoint — fall through to replace it with captured data
|
|
376
|
+
} else {
|
|
377
|
+
// Non-skeleton duplicate — store key for body diffing, return null
|
|
378
|
+
this._lastDedupKey = key;
|
|
379
|
+
return null;
|
|
355
380
|
}
|
|
356
|
-
return null;
|
|
357
381
|
}
|
|
358
382
|
|
|
383
|
+
const isSkeletonReplacement = this.endpoints.has(key)
|
|
384
|
+
&& this.endpoints.get(key)!.endpointProvenance === 'skeleton';
|
|
385
|
+
|
|
359
386
|
// Detect OAuth token requests from captured traffic
|
|
360
|
-
const oauthInfo = isOAuthTokenRequest(
|
|
387
|
+
const oauthInfo = isOAuthTokenRequest(request);
|
|
361
388
|
if (oauthInfo && !this.oauthConfig) {
|
|
362
389
|
this.oauthConfig = {
|
|
363
390
|
tokenEndpoint: oauthInfo.tokenEndpoint,
|
|
@@ -370,11 +397,11 @@ export class SkillGenerator {
|
|
|
370
397
|
}
|
|
371
398
|
|
|
372
399
|
// Extract auth before filtering headers (includes entropy-based detection)
|
|
373
|
-
const [auth, entropyDetected] = extractAuth(
|
|
400
|
+
const [auth, entropyDetected] = extractAuth(request.headers);
|
|
374
401
|
this.extractedAuthList.push(...auth);
|
|
375
402
|
|
|
376
403
|
// Filter headers, then strip auth values (including entropy-detected tokens)
|
|
377
|
-
const filtered = filterHeaders(
|
|
404
|
+
const filtered = filterHeaders(request.headers);
|
|
378
405
|
const safeHeaders = stripAuth(filtered, entropyDetected);
|
|
379
406
|
|
|
380
407
|
// Build query params, optionally scrub PII
|
|
@@ -384,11 +411,50 @@ export class SkillGenerator {
|
|
|
384
411
|
}
|
|
385
412
|
|
|
386
413
|
// Build example URL, optionally scrub PII and sensitive query params
|
|
387
|
-
let exampleUrl =
|
|
414
|
+
let exampleUrl = request.url;
|
|
388
415
|
if (this.options.scrub) {
|
|
389
416
|
exampleUrl = scrubUrlQueryParams(scrubPII(exampleUrl));
|
|
390
417
|
}
|
|
391
418
|
|
|
419
|
+
// Generate endpoint ID - use GraphQL operation name if applicable
|
|
420
|
+
const endpointId = graphqlInfo
|
|
421
|
+
? `${method.toLowerCase()}-graphql-${graphqlInfo.operationName}`
|
|
422
|
+
: generateEndpointId(method, paramPath);
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
key,
|
|
426
|
+
method,
|
|
427
|
+
paramPath,
|
|
428
|
+
queryParams,
|
|
429
|
+
safeHeaders,
|
|
430
|
+
exampleUrl,
|
|
431
|
+
endpointId,
|
|
432
|
+
graphqlInfo,
|
|
433
|
+
entropyDetected,
|
|
434
|
+
isSkeletonReplacement,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Add a captured exchange. Returns the new endpoint if first seen, null if duplicate. */
|
|
439
|
+
addExchange(exchange: CapturedExchange): SkillEndpoint | null {
|
|
440
|
+
// Track response bytes for all exchanges (for browser cost measurement)
|
|
441
|
+
this.totalNetworkBytes += exchange.response.body.length;
|
|
442
|
+
|
|
443
|
+
const prep = this._prepareEndpoint(exchange.request);
|
|
444
|
+
|
|
445
|
+
if (!prep) {
|
|
446
|
+
// Dedup: store body for cross-request diffing (Strategy 1) — scrubbed (H3 fix)
|
|
447
|
+
const dedupKey = this._lastDedupKey;
|
|
448
|
+
if (dedupKey && exchange.request.postData) {
|
|
449
|
+
const bodies = this.exchangeBodies.get(dedupKey);
|
|
450
|
+
if (bodies) bodies.push(this.scrubBodyString(exchange.request.postData));
|
|
451
|
+
}
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const { key, method, paramPath, queryParams, safeHeaders, exampleUrl,
|
|
456
|
+
endpointId, graphqlInfo, entropyDetected } = prep;
|
|
457
|
+
|
|
392
458
|
// Response preview: null by default, populated with --preview
|
|
393
459
|
let responsePreview: unknown = null;
|
|
394
460
|
if (this.options.enablePreview) {
|
|
@@ -445,11 +511,6 @@ export class SkillGenerator {
|
|
|
445
511
|
}
|
|
446
512
|
}
|
|
447
513
|
|
|
448
|
-
// Generate endpoint ID - use GraphQL operation name if applicable
|
|
449
|
-
const endpointId = graphqlInfo
|
|
450
|
-
? `${method.toLowerCase()}-graphql-${graphqlInfo.operationName}`
|
|
451
|
-
: generateEndpointId(method, paramPath);
|
|
452
|
-
|
|
453
514
|
const endpoint: SkillEndpoint = {
|
|
454
515
|
id: endpointId,
|
|
455
516
|
method: exchange.request.method,
|
|
@@ -503,6 +564,52 @@ export class SkillGenerator {
|
|
|
503
564
|
return endpoint;
|
|
504
565
|
}
|
|
505
566
|
|
|
567
|
+
/** Add a skeleton endpoint (request data only, no response body). Confidence 0.8. */
|
|
568
|
+
addSkeleton(request: {
|
|
569
|
+
url: string;
|
|
570
|
+
method: string;
|
|
571
|
+
headers: Record<string, string>;
|
|
572
|
+
postData?: string;
|
|
573
|
+
}): SkillEndpoint | null {
|
|
574
|
+
const prep = this._prepareEndpoint(request);
|
|
575
|
+
if (!prep) return null;
|
|
576
|
+
|
|
577
|
+
const { key, paramPath, queryParams, safeHeaders, exampleUrl,
|
|
578
|
+
endpointId, entropyDetected, isSkeletonReplacement } = prep;
|
|
579
|
+
|
|
580
|
+
// If a skeleton already occupies this key, don't overwrite with another skeleton
|
|
581
|
+
if (isSkeletonReplacement) return null;
|
|
582
|
+
|
|
583
|
+
const endpoint: SkillEndpoint = {
|
|
584
|
+
id: endpointId,
|
|
585
|
+
method: request.method,
|
|
586
|
+
path: paramPath,
|
|
587
|
+
queryParams,
|
|
588
|
+
headers: safeHeaders,
|
|
589
|
+
responseShape: { type: 'unknown' },
|
|
590
|
+
examples: {
|
|
591
|
+
request: {
|
|
592
|
+
url: exampleUrl,
|
|
593
|
+
headers: stripAuth(filterHeaders(request.headers)),
|
|
594
|
+
},
|
|
595
|
+
responsePreview: null,
|
|
596
|
+
},
|
|
597
|
+
confidence: 0.8,
|
|
598
|
+
endpointProvenance: 'skeleton',
|
|
599
|
+
responseBytes: 0,
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// Also strip entropy-detected tokens from example headers
|
|
603
|
+
if (entropyDetected.size > 0) {
|
|
604
|
+
endpoint.examples.request.headers = stripAuth(
|
|
605
|
+
filterHeaders(request.headers), entropyDetected
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
this.endpoints.set(key, endpoint);
|
|
610
|
+
return endpoint;
|
|
611
|
+
}
|
|
612
|
+
|
|
506
613
|
/** Record a filtered-out request (for metadata tracking). */
|
|
507
614
|
recordFiltered(): void {
|
|
508
615
|
this.filteredCount++;
|
package/src/skill/merge.ts
CHANGED
|
@@ -82,6 +82,33 @@ function wouldEnrich(existing: SkillEndpoint, imported: SkillEndpoint): boolean
|
|
|
82
82
|
return false;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Determine whether an imported endpoint would enrich an existing skeleton
|
|
87
|
+
* endpoint. Skeletons are enrichable by imports: imports can provide response
|
|
88
|
+
* shape (replacing the placeholder `{ type: 'unknown' }`), description,
|
|
89
|
+
* specSource, and query param metadata.
|
|
90
|
+
*/
|
|
91
|
+
function wouldEnrichSkeleton(existing: SkillEndpoint, imported: SkillEndpoint): boolean {
|
|
92
|
+
// Import can replace an unknown response shape
|
|
93
|
+
const existingShape = existing.responseShape;
|
|
94
|
+
const isUnknownShape = existingShape.type === 'unknown' && !existingShape.fields;
|
|
95
|
+
if (isUnknownShape && imported.responseShape.type !== 'unknown') return true;
|
|
96
|
+
|
|
97
|
+
if (!existing.description && imported.description) return true;
|
|
98
|
+
if (!existing.specSource && imported.specSource) return true;
|
|
99
|
+
|
|
100
|
+
// Check if any new query params would be added or enriched
|
|
101
|
+
for (const [name, specParam] of Object.entries(imported.queryParams)) {
|
|
102
|
+
if (!(name in existing.queryParams)) return true;
|
|
103
|
+
const ep = existing.queryParams[name];
|
|
104
|
+
if (!ep.enum && specParam.enum) return true;
|
|
105
|
+
if (ep.required === undefined && specParam.required !== undefined) return true;
|
|
106
|
+
if (ep.type === 'string' && specParam.type !== 'string') return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
85
112
|
/**
|
|
86
113
|
* Pure function — no I/O.
|
|
87
114
|
*
|
|
@@ -182,6 +209,51 @@ export function mergeSkillFile(
|
|
|
182
209
|
continue;
|
|
183
210
|
}
|
|
184
211
|
|
|
212
|
+
// --- Skeleton branch: import can enrich skeleton endpoints ---
|
|
213
|
+
if (existingEp.endpointProvenance === 'skeleton') {
|
|
214
|
+
if (!wouldEnrichSkeleton(existingEp, importedEp)) {
|
|
215
|
+
resultEndpoints.push({
|
|
216
|
+
...existingEp,
|
|
217
|
+
normalizedPath: normalizePath(existingEp.path),
|
|
218
|
+
});
|
|
219
|
+
const existingHasSpecData = !!(existingEp.specSource || existingEp.description);
|
|
220
|
+
const importHasSpecData = !!(importedEp.specSource || importedEp.description);
|
|
221
|
+
if (existingHasSpecData || importHasSpecData) {
|
|
222
|
+
skipped++;
|
|
223
|
+
} else {
|
|
224
|
+
preserved++;
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const mergedQueryParams = mergeQueryParams(existingEp.queryParams, importedEp.queryParams);
|
|
230
|
+
|
|
231
|
+
// For skeletons, import can replace responseShape if existing is unknown
|
|
232
|
+
const existingShape = existingEp.responseShape;
|
|
233
|
+
const isUnknownShape = existingShape.type === 'unknown' && !existingShape.fields;
|
|
234
|
+
const responseShape = isUnknownShape && importedEp.responseShape.type !== 'unknown'
|
|
235
|
+
? importedEp.responseShape
|
|
236
|
+
: existingShape;
|
|
237
|
+
|
|
238
|
+
const enrichedSkeleton: SkillEndpoint = {
|
|
239
|
+
...existingEp,
|
|
240
|
+
normalizedPath: normalizePath(existingEp.path),
|
|
241
|
+
responseShape,
|
|
242
|
+
// Augment with spec fields (only if not already present)
|
|
243
|
+
...(importedEp.description && !existingEp.description ? { description: importedEp.description } : {}),
|
|
244
|
+
...(importedEp.specSource && !existingEp.specSource ? { specSource: importedEp.specSource } : {}),
|
|
245
|
+
// Confidence = max(skeleton, import)
|
|
246
|
+
confidence: Math.max(existingEp.confidence ?? 0, importedEp.confidence ?? 0) || existingEp.confidence,
|
|
247
|
+
// Provenance stays 'skeleton'
|
|
248
|
+
endpointProvenance: 'skeleton',
|
|
249
|
+
queryParams: mergedQueryParams,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
resultEndpoints.push(enrichedSkeleton);
|
|
253
|
+
enriched++;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
185
257
|
// Imported endpoint matches an existing one — check if it would add anything new
|
|
186
258
|
if (!wouldEnrich(existingEp, importedEp)) {
|
|
187
259
|
resultEndpoints.push({
|
package/src/types.ts
CHANGED
|
@@ -133,7 +133,7 @@ export interface SkillEndpoint {
|
|
|
133
133
|
responseSchema?: SchemaNode; // v1.1: schema snapshot for contract validation
|
|
134
134
|
normalizedPath?: string;
|
|
135
135
|
confidence?: number;
|
|
136
|
-
endpointProvenance?: 'captured' | 'openapi-import';
|
|
136
|
+
endpointProvenance?: 'captured' | 'openapi-import' | 'skeleton';
|
|
137
137
|
specSource?: string;
|
|
138
138
|
description?: string;
|
|
139
139
|
}
|