@apitap/core 1.6.1 → 1.6.3

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.
@@ -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
- /** Add a captured exchange. Returns the new endpoint if first seen, null if duplicate. */
309
- addExchange(exchange: CapturedExchange): SkillEndpoint | null {
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(exchange.request.url);
313
- const method = exchange.request.method;
314
- const contentType = exchange.request.headers['content-type'] ?? '';
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, exchange.request.postData ?? null);
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 && exchange.request.postData) {
326
- const parsed = parseGraphQLBody(exchange.request.postData);
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
- // Store duplicate body for cross-request diffing (Strategy 1) — scrubbed (H3 fix)
352
- if (exchange.request.postData) {
353
- const bodies = this.exchangeBodies.get(key);
354
- if (bodies) bodies.push(this.scrubBodyString(exchange.request.postData));
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(exchange.request);
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(exchange.request.headers);
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(exchange.request.headers);
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 = exchange.request.url;
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++;
@@ -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
  }