@apitap/core 1.5.4 → 1.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.
@@ -0,0 +1,434 @@
1
+ // src/skill/openapi-converter.ts
2
+ import type { SkillEndpoint, ImportResult, ImportMeta, RequestBody } from '../types.js';
3
+
4
+ /**
5
+ * Resolve a JSON $ref pointer in an OpenAPI spec.
6
+ * Uses a visited set to detect cycles and a depth limit as safety net.
7
+ */
8
+ export function resolveRef(
9
+ obj: any,
10
+ spec: Record<string, any>,
11
+ visited: Set<string> = new Set(),
12
+ depth: number = 0,
13
+ ): any {
14
+ if (!obj || typeof obj !== 'object') return obj;
15
+
16
+ // Handle allOf composition: merge properties from all entries
17
+ if (obj.allOf && Array.isArray(obj.allOf)) {
18
+ const merged: Record<string, any> = { type: 'object', properties: {} };
19
+ for (const entry of obj.allOf) {
20
+ const resolved = resolveRef(entry, spec, new Set(visited), depth + 1);
21
+ if (resolved?.properties) {
22
+ Object.assign(merged.properties, resolved.properties);
23
+ }
24
+ if (resolved?.required) {
25
+ merged.required = [...(merged.required || []), ...resolved.required];
26
+ }
27
+ if (resolved?.description && !merged.description) {
28
+ merged.description = resolved.description;
29
+ }
30
+ }
31
+ return merged;
32
+ }
33
+
34
+ // Handle oneOf/anyOf: use first option as representative
35
+ if (obj.oneOf && Array.isArray(obj.oneOf) && obj.oneOf.length > 0) {
36
+ return resolveRef(obj.oneOf[0], spec, new Set(visited), depth + 1);
37
+ }
38
+ if (obj.anyOf && Array.isArray(obj.anyOf) && obj.anyOf.length > 0) {
39
+ return resolveRef(obj.anyOf[0], spec, new Set(visited), depth + 1);
40
+ }
41
+
42
+ if (!obj.$ref) return obj;
43
+
44
+ const ref = obj.$ref as string;
45
+ if (!ref.startsWith('#/')) return null; // Only local document refs supported
46
+ if (visited.has(ref)) return null; // cycle detected
47
+ if (depth > 10) return null; // depth safety net
48
+
49
+ visited.add(ref);
50
+
51
+ const refPath = ref.replace('#/', '').split('/');
52
+ let resolved: any = spec;
53
+ for (const segment of refPath) {
54
+ resolved = resolved?.[segment];
55
+ if (resolved === undefined) return null;
56
+ }
57
+
58
+ // Recursively resolve if the resolved object also has $ref or allOf
59
+ return resolveRef(resolved, spec, visited, depth + 1);
60
+ }
61
+
62
+ export function extractDomainAndBasePath(
63
+ spec: Record<string, any>,
64
+ specUrl: string,
65
+ ): { domain: string; basePath: string } {
66
+ const serverUrl = spec.servers?.[0]?.url;
67
+ if (serverUrl) {
68
+ try {
69
+ const parsed = new URL(serverUrl);
70
+ return { domain: parsed.hostname, basePath: parsed.pathname.replace(/\/$/, '') };
71
+ } catch {
72
+ if (serverUrl.startsWith('/')) {
73
+ const providerName = spec.info?.['x-providerName'];
74
+ const domain = (providerName && /^[a-zA-Z0-9][a-zA-Z0-9._-]+$/.test(providerName))
75
+ ? providerName
76
+ : new URL(specUrl).hostname;
77
+ return { domain, basePath: serverUrl.replace(/\/$/, '') };
78
+ }
79
+ }
80
+ }
81
+ if (spec.host) {
82
+ try {
83
+ const parsed = new URL(`https://${spec.host}`);
84
+ return { domain: parsed.hostname, basePath: (spec.basePath || '').replace(/\/$/, '') };
85
+ } catch {
86
+ return { domain: spec.host.split(':')[0], basePath: (spec.basePath || '').replace(/\/$/, '') };
87
+ }
88
+ }
89
+ try {
90
+ return { domain: new URL(specUrl).hostname, basePath: '' };
91
+ } catch {
92
+ throw new Error(`Cannot determine API domain from spec (no servers, host, or valid specUrl)`);
93
+ }
94
+ }
95
+
96
+ export interface ConfidenceInput {
97
+ method: string;
98
+ hasExamples: boolean;
99
+ requiresAuth: boolean;
100
+ }
101
+
102
+ export function computeConfidence(input: ConfidenceInput): number {
103
+ let score = 0.6;
104
+ if (input.hasExamples) score += 0.1;
105
+ if (!input.requiresAuth) score += 0.1;
106
+ if (input.method === 'GET') score += 0.05;
107
+ return Math.min(score, 0.85);
108
+ }
109
+
110
+ export type AuthType = 'apiKey' | 'oauth2' | 'bearer' | 'basic' | 'openIdConnect';
111
+
112
+ export function detectAuth(spec: Record<string, any>): { requiresAuth: boolean; authType?: AuthType } {
113
+ const schemes = spec.components?.securitySchemes || {};
114
+ const defs = spec.securityDefinitions || {};
115
+ const allSchemes = { ...schemes, ...defs };
116
+ const security = spec.security || [];
117
+
118
+ if (Object.keys(allSchemes).length === 0 && security.length === 0) {
119
+ return { requiresAuth: false };
120
+ }
121
+
122
+ let authType: AuthType | undefined;
123
+ for (const scheme of Object.values(allSchemes) as any[]) {
124
+ if (scheme.type === 'http' && scheme.scheme === 'bearer') { authType = 'bearer'; break; }
125
+ if (scheme.type === 'http' && scheme.scheme === 'basic') { authType = 'basic'; break; }
126
+ if (scheme.type === 'apiKey') { authType = 'apiKey'; break; }
127
+ if (scheme.type === 'oauth2') { authType = 'oauth2'; break; }
128
+ if (scheme.type === 'openIdConnect') { authType = 'openIdConnect'; break; }
129
+ }
130
+
131
+ return { requiresAuth: true, authType };
132
+ }
133
+
134
+ export function generateEndpointId(
135
+ method: string,
136
+ path: string,
137
+ operationId: string | undefined,
138
+ seen: Set<string>,
139
+ ): string {
140
+ let base: string;
141
+ if (operationId) {
142
+ base = `${method.toLowerCase()}-${operationId.replace(/[^a-z0-9]/gi, '-').toLowerCase()}`;
143
+ } else {
144
+ const segments = path.split('/').filter(s => s !== '' && !s.startsWith(':')).join('-').replace(/[^a-z0-9-]/gi, '').toLowerCase() || 'root';
145
+ base = `${method.toLowerCase()}-${segments}`;
146
+ }
147
+ base = base.replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 80);
148
+ let id = base;
149
+ let counter = 2;
150
+ while (seen.has(id)) {
151
+ id = `${base}-${counter}`.slice(0, 80);
152
+ counter++;
153
+ }
154
+ seen.add(id);
155
+ return id;
156
+ }
157
+
158
+ const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const;
159
+ const MAX_ENDPOINTS = 500;
160
+
161
+ /**
162
+ * Detect whether a parsed JSON object is an OpenAPI/Swagger spec.
163
+ * Returns false for SkillFile objects (which have version+domain+baseUrl+endpoints).
164
+ */
165
+ export function isOpenAPISpec(obj: unknown): boolean {
166
+ if (!obj || typeof obj !== 'object') return false;
167
+ const o = obj as Record<string, unknown>;
168
+
169
+ // SkillFile check: if it looks like a SkillFile, it's not an OpenAPI spec
170
+ if (o.version && o.domain && o.baseUrl && Array.isArray(o.endpoints)) {
171
+ return false;
172
+ }
173
+
174
+ // OpenAPI 3.x or Swagger 2.0
175
+ if (typeof o.openapi === 'string' || typeof o.swagger === 'string') {
176
+ return true;
177
+ }
178
+
179
+ return false;
180
+ }
181
+
182
+ /**
183
+ * Extract response shape (type + top-level field names) from an OpenAPI response schema.
184
+ */
185
+ function extractResponseShape(
186
+ responses: Record<string, any> | undefined,
187
+ spec: Record<string, any>,
188
+ ): { type: string; fields?: string[] } {
189
+ if (!responses) return { type: 'unknown' };
190
+
191
+ // Try 200, then 201, then first 2xx
192
+ const responseObj =
193
+ responses['200'] || responses['201'] ||
194
+ Object.entries(responses).find(([k]) => k.startsWith('2'))?.[1];
195
+ if (!responseObj) return { type: 'unknown' };
196
+
197
+ const resolved = resolveRef(responseObj, spec);
198
+ if (!resolved) return { type: 'unknown' };
199
+
200
+ // OpenAPI 3.x: content -> application/json -> schema
201
+ let schema = resolved.content?.['application/json']?.schema;
202
+ // Swagger 2.0: schema directly on response
203
+ if (!schema && resolved.schema) schema = resolved.schema;
204
+ if (!schema) return { type: 'unknown' };
205
+
206
+ schema = resolveRef(schema, spec);
207
+ if (!schema) return { type: 'unknown' };
208
+
209
+ const type = schema.type === 'array' ? 'array' : schema.type === 'object' ? 'object' : (schema.type || 'unknown');
210
+ const fields: string[] = [];
211
+
212
+ if (schema.properties) {
213
+ fields.push(...Object.keys(schema.properties));
214
+ } else if (schema.type === 'array' && schema.items) {
215
+ const items = resolveRef(schema.items, spec);
216
+ if (items?.properties) {
217
+ fields.push(...Object.keys(items.properties));
218
+ }
219
+ }
220
+
221
+ return fields.length > 0 ? { type, fields } : { type };
222
+ }
223
+
224
+ /**
225
+ * Extract request body template for POST/PUT/PATCH from an OpenAPI operation.
226
+ */
227
+ function extractRequestBody(
228
+ operation: Record<string, any>,
229
+ spec: Record<string, any>,
230
+ ): RequestBody | undefined {
231
+ // OpenAPI 3.x: requestBody -> content -> application/json -> schema
232
+ let schema: any;
233
+ let contentType = 'application/json';
234
+
235
+ if (operation.requestBody) {
236
+ const body = resolveRef(operation.requestBody, spec);
237
+ if (!body) return undefined;
238
+ const jsonContent = body.content?.['application/json'];
239
+ if (jsonContent?.schema) {
240
+ schema = resolveRef(jsonContent.schema, spec);
241
+ } else {
242
+ // Try first content type
243
+ const firstKey = body.content ? Object.keys(body.content)[0] : undefined;
244
+ if (firstKey && body.content[firstKey]?.schema) {
245
+ contentType = firstKey;
246
+ schema = resolveRef(body.content[firstKey].schema, spec);
247
+ }
248
+ }
249
+ }
250
+
251
+ // Swagger 2.0: parameters with in=body
252
+ if (!schema && operation.parameters) {
253
+ const bodyParam = operation.parameters.find((p: any) => p.in === 'body');
254
+ if (bodyParam?.schema) {
255
+ schema = resolveRef(bodyParam.schema, spec);
256
+ }
257
+ }
258
+
259
+ if (!schema) return undefined;
260
+
261
+ const template: Record<string, unknown> = {};
262
+ const variables: string[] = [];
263
+
264
+ if (schema.properties) {
265
+ for (const [key, prop] of Object.entries(schema.properties) as [string, any][]) {
266
+ const resolvedProp = resolveRef(prop, spec) || prop;
267
+ if (resolvedProp.example !== undefined) {
268
+ template[key] = resolvedProp.example;
269
+ } else if (resolvedProp.default !== undefined) {
270
+ template[key] = resolvedProp.default;
271
+ } else {
272
+ template[key] = `{{${key}}}`;
273
+ variables.push(key);
274
+ }
275
+ }
276
+ }
277
+
278
+ return {
279
+ contentType,
280
+ template: Object.keys(template).length > 0 ? template : '{{body}}',
281
+ ...(variables.length > 0 ? { variables } : {}),
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Convert an OpenAPI 3.x or Swagger 2.0 spec into an ImportResult.
287
+ */
288
+ export function convertOpenAPISpec(
289
+ spec: Record<string, any>,
290
+ specUrl: string,
291
+ ): ImportResult {
292
+ const { domain, basePath } = extractDomainAndBasePath(spec, specUrl);
293
+ const { requiresAuth, authType } = detectAuth(spec);
294
+ const specVersion: 'openapi3' | 'swagger2' = spec.swagger ? 'swagger2' : 'openapi3';
295
+
296
+ const endpoints: SkillEndpoint[] = [];
297
+ const seenIds = new Set<string>();
298
+ const paths = spec.paths || {};
299
+
300
+ for (const [pathKey, pathItem] of Object.entries(paths) as [string, any][]) {
301
+ if (!pathItem || typeof pathItem !== 'object') continue;
302
+
303
+ for (const method of HTTP_METHODS) {
304
+ const operation = pathItem[method];
305
+ if (!operation || typeof operation !== 'object') continue;
306
+
307
+ // Collect all parameters (path-level + operation-level)
308
+ const allParams: any[] = [
309
+ ...(pathItem.parameters || []),
310
+ ...(operation.parameters || []),
311
+ ];
312
+
313
+ // Convert path: {param} -> :param, prepend basePath
314
+ const convertedPath = basePath + pathKey.replace(/\{([^}]+)\}/g, ':$1');
315
+
316
+ // Extract query params
317
+ const queryParams: SkillEndpoint['queryParams'] = {};
318
+ for (const param of allParams) {
319
+ const resolved = resolveRef(param, spec) || param;
320
+ if (resolved.in === 'query') {
321
+ const paramSchema = resolved.schema ? (resolveRef(resolved.schema, spec) || resolved.schema) : resolved;
322
+ const example = resolved.example ?? paramSchema?.example ?? paramSchema?.default ?? '';
323
+ queryParams[resolved.name] = {
324
+ type: paramSchema?.type || 'string',
325
+ example: example !== '' ? String(example) : '',
326
+ fromSpec: true,
327
+ ...(resolved.required ? { required: true } : {}),
328
+ ...(paramSchema?.enum ? { enum: paramSchema.enum } : {}),
329
+ };
330
+ }
331
+ }
332
+
333
+ // Extract path param examples for the example URL
334
+ const pathParamExamples: Record<string, string> = {};
335
+ let hasExamples = false;
336
+ for (const param of allParams) {
337
+ const resolved = resolveRef(param, spec) || param;
338
+ if (resolved.in === 'path') {
339
+ const paramSchema = resolved.schema ? (resolveRef(resolved.schema, spec) || resolved.schema) : resolved;
340
+ const example = resolved.example ?? paramSchema?.example ?? paramSchema?.default;
341
+ if (example !== undefined) {
342
+ pathParamExamples[resolved.name] = String(example);
343
+ hasExamples = true;
344
+ }
345
+ }
346
+ }
347
+
348
+ // Check if query params have examples too
349
+ if (!hasExamples) {
350
+ for (const qp of Object.values(queryParams)) {
351
+ if (qp.example !== '') { hasExamples = true; break; }
352
+ }
353
+ }
354
+
355
+ // Build example URL
356
+ let examplePath = basePath + pathKey;
357
+ // Substitute path params with examples
358
+ for (const [name, value] of Object.entries(pathParamExamples)) {
359
+ examplePath = examplePath.replace(`{${name}}`, value);
360
+ }
361
+ // Replace remaining unsubstituted path params with placeholder
362
+ examplePath = examplePath.replace(/\{([^}]+)\}/g, ':$1');
363
+
364
+ // Add non-empty query params to URL
365
+ const queryEntries = Object.entries(queryParams).filter(([, v]) => v.example !== '');
366
+ const queryString = queryEntries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v.example)}`).join('&');
367
+ const exampleUrl = `https://${domain}${examplePath}${queryString ? '?' + queryString : ''}`;
368
+
369
+ // Extract response shape
370
+ const responseShape = extractResponseShape(operation.responses, spec);
371
+
372
+ // Extract request body for write methods
373
+ const methodUpper = method.toUpperCase();
374
+ let requestBody: RequestBody | undefined;
375
+ if (['POST', 'PUT', 'PATCH'].includes(methodUpper)) {
376
+ requestBody = extractRequestBody(operation, spec);
377
+ }
378
+
379
+ // Description
380
+ const description = operation.summary || operation.description || undefined;
381
+
382
+ // Generate endpoint ID
383
+ const id = generateEndpointId(method, convertedPath, operation.operationId, seenIds);
384
+
385
+ // Compute confidence
386
+ const confidence = computeConfidence({
387
+ method: methodUpper,
388
+ hasExamples,
389
+ requiresAuth,
390
+ });
391
+
392
+ const endpoint: SkillEndpoint = {
393
+ id,
394
+ method: methodUpper,
395
+ path: convertedPath,
396
+ queryParams,
397
+ headers: {},
398
+ responseShape,
399
+ examples: {
400
+ request: { url: exampleUrl, headers: {} },
401
+ responsePreview: null,
402
+ },
403
+ confidence,
404
+ endpointProvenance: 'openapi-import',
405
+ specSource: specUrl,
406
+ ...(description ? { description } : {}),
407
+ ...(requestBody ? { requestBody } : {}),
408
+ };
409
+
410
+ endpoints.push(endpoint);
411
+ }
412
+ }
413
+
414
+ // Sort by confidence descending, then truncate
415
+ endpoints.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0));
416
+ if (endpoints.length > MAX_ENDPOINTS) {
417
+ process.stderr.write(
418
+ `[openapi-import] Warning: spec has ${endpoints.length} endpoints, truncating to ${MAX_ENDPOINTS}\n`,
419
+ );
420
+ endpoints.length = MAX_ENDPOINTS;
421
+ }
422
+
423
+ const meta: ImportMeta = {
424
+ specUrl,
425
+ specVersion,
426
+ title: spec.info?.title || '',
427
+ description: spec.info?.description || '',
428
+ requiresAuth,
429
+ ...(authType ? { authType } : {}),
430
+ endpointCount: endpoints.length,
431
+ };
432
+
433
+ return { domain, endpoints, meta };
434
+ }
@@ -44,6 +44,20 @@ export function signSkillFile(skill: SkillFile, key: Buffer): SkillFile {
44
44
  };
45
45
  }
46
46
 
47
+ /**
48
+ * Sign a skill file with a specific provenance value.
49
+ * Use 'self' for captured files, 'imported-signed' for import-only files.
50
+ */
51
+ export function signSkillFileAs(
52
+ skill: SkillFile,
53
+ key: Buffer,
54
+ provenance: 'self' | 'imported-signed',
55
+ ): SkillFile {
56
+ const payload = canonicalize(skill);
57
+ const signature = hmacSign(payload, key);
58
+ return { ...skill, provenance, signature };
59
+ }
60
+
47
61
  /**
48
62
  * Verify a skill file's signature.
49
63
  * Returns true if the signature is valid for the given key.
@@ -1,5 +1,5 @@
1
1
  // src/skill/store.ts
2
- import { readFile, writeFile, mkdir, readdir, access } from 'node:fs/promises';
2
+ import { readFile, writeFile, mkdir, readdir, access, rename } from 'node:fs/promises';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import type { SkillFile, SkillSummary } from '../types.js';
@@ -40,7 +40,10 @@ export async function writeSkillFile(
40
40
  await mkdir(skillsDir, { recursive: true, mode: 0o700 });
41
41
  await ensureGitignore(skillsDir);
42
42
  const filePath = skillPath(skill.domain, skillsDir);
43
- await writeFile(filePath, JSON.stringify(skill, null, 2) + '\n', { mode: 0o600 });
43
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
44
+ const content = JSON.stringify(skill, null, 2) + '\n';
45
+ await writeFile(tmpPath, content, { mode: 0o600 });
46
+ await rename(tmpPath, filePath);
44
47
  return filePath;
45
48
  }
46
49
 
@@ -114,8 +117,9 @@ export async function listSkillFiles(
114
117
  let files: string[];
115
118
  try {
116
119
  files = await readdir(skillsDir);
117
- } catch {
118
- return [];
120
+ } catch (err: unknown) {
121
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
122
+ throw err;
119
123
  }
120
124
 
121
125
  const summaries: SkillSummary[] = [];
package/src/types.ts CHANGED
@@ -118,7 +118,7 @@ export interface SkillEndpoint {
118
118
  id: string;
119
119
  method: string;
120
120
  path: string;
121
- queryParams: Record<string, { type: string; example: string }>;
121
+ queryParams: Record<string, { type: string; example: string; required?: boolean; enum?: string[]; fromSpec?: boolean }>;
122
122
  headers: Record<string, string>;
123
123
  responseShape: { type: string; fields?: string[] };
124
124
  examples: {
@@ -131,6 +131,11 @@ export interface SkillEndpoint {
131
131
  responseBytes?: number; // v1.0: response body size in bytes
132
132
  isolatedAuth?: boolean; // v1.1: opt out of cross-subdomain auth fallback
133
133
  responseSchema?: SchemaNode; // v1.1: schema snapshot for contract validation
134
+ normalizedPath?: string;
135
+ confidence?: number;
136
+ endpointProvenance?: 'captured' | 'openapi-import';
137
+ specSource?: string;
138
+ description?: string;
134
139
  }
135
140
 
136
141
  /** The full skill file written to disk */
@@ -149,8 +154,15 @@ export interface SkillFile {
149
154
  totalNetworkBytes: number; // sum of ALL response body sizes
150
155
  totalRequests: number;
151
156
  };
157
+ importHistory?: Array<{
158
+ specUrl: string;
159
+ specVersion: 'openapi3' | 'swagger2';
160
+ importedAt: string;
161
+ endpointsAdded: number;
162
+ endpointsEnriched: number;
163
+ }>;
152
164
  };
153
- provenance: 'self' | 'imported' | 'unsigned';
165
+ provenance: 'self' | 'imported' | 'imported-signed' | 'unsigned';
154
166
  signature?: string;
155
167
  auth?: SkillAuth; // v0.8: top-level auth config
156
168
  }
@@ -203,7 +215,7 @@ export interface SkillSummary {
203
215
  skillFile: string;
204
216
  endpointCount: number;
205
217
  capturedAt: string;
206
- provenance: 'self' | 'imported' | 'unsigned';
218
+ provenance: 'self' | 'imported' | 'imported-signed' | 'unsigned';
207
219
  }
208
220
 
209
221
  // --- Discovery types (Milestone 2: Smart Discovery) ---
@@ -245,3 +257,32 @@ export interface DiscoveryResult {
245
257
  authSignals?: string[]; // reasons auth was detected
246
258
  loginUrl?: string; // detected login page URL
247
259
  }
260
+
261
+ /** Metadata about an OpenAPI spec import */
262
+ export interface ImportMeta {
263
+ specUrl: string;
264
+ specVersion: 'openapi3' | 'swagger2';
265
+ title: string;
266
+ description: string;
267
+ requiresAuth: boolean;
268
+ authType?: 'apiKey' | 'oauth2' | 'bearer' | 'basic' | 'openIdConnect';
269
+ endpointCount: number;
270
+ }
271
+
272
+ /** Result of converting an OpenAPI spec */
273
+ export interface ImportResult {
274
+ domain: string;
275
+ endpoints: SkillEndpoint[];
276
+ meta: ImportMeta;
277
+ }
278
+
279
+ /** Result of merging imported endpoints into an existing skill file */
280
+ export interface MergeResult {
281
+ skillFile: SkillFile;
282
+ diff: {
283
+ preserved: number;
284
+ added: number;
285
+ enriched: number;
286
+ skipped: number;
287
+ };
288
+ }