truss-api-mcp 1.0.0

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 (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +89 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/lib/code-generator.d.ts +6 -0
  8. package/dist/lib/code-generator.d.ts.map +1 -0
  9. package/dist/lib/code-generator.js +890 -0
  10. package/dist/lib/code-generator.js.map +1 -0
  11. package/dist/lib/http-client.d.ts +6 -0
  12. package/dist/lib/http-client.d.ts.map +1 -0
  13. package/dist/lib/http-client.js +76 -0
  14. package/dist/lib/http-client.js.map +1 -0
  15. package/dist/lib/license.d.ts +4 -0
  16. package/dist/lib/license.d.ts.map +1 -0
  17. package/dist/lib/license.js +97 -0
  18. package/dist/lib/license.js.map +1 -0
  19. package/dist/lib/openapi-parser.d.ts +11 -0
  20. package/dist/lib/openapi-parser.d.ts.map +1 -0
  21. package/dist/lib/openapi-parser.js +390 -0
  22. package/dist/lib/openapi-parser.js.map +1 -0
  23. package/dist/lib/schema-validator.d.ts +15 -0
  24. package/dist/lib/schema-validator.d.ts.map +1 -0
  25. package/dist/lib/schema-validator.js +206 -0
  26. package/dist/lib/schema-validator.js.map +1 -0
  27. package/dist/tools/compare-specs.d.ts +3 -0
  28. package/dist/tools/compare-specs.d.ts.map +1 -0
  29. package/dist/tools/compare-specs.js +59 -0
  30. package/dist/tools/compare-specs.js.map +1 -0
  31. package/dist/tools/generate-client.d.ts +3 -0
  32. package/dist/tools/generate-client.d.ts.map +1 -0
  33. package/dist/tools/generate-client.js +65 -0
  34. package/dist/tools/generate-client.js.map +1 -0
  35. package/dist/tools/generate-openapi.d.ts +3 -0
  36. package/dist/tools/generate-openapi.d.ts.map +1 -0
  37. package/dist/tools/generate-openapi.js +57 -0
  38. package/dist/tools/generate-openapi.js.map +1 -0
  39. package/dist/tools/generate-tests.d.ts +3 -0
  40. package/dist/tools/generate-tests.d.ts.map +1 -0
  41. package/dist/tools/generate-tests.js +59 -0
  42. package/dist/tools/generate-tests.js.map +1 -0
  43. package/dist/tools/mock-server.d.ts +3 -0
  44. package/dist/tools/mock-server.d.ts.map +1 -0
  45. package/dist/tools/mock-server.js +60 -0
  46. package/dist/tools/mock-server.js.map +1 -0
  47. package/dist/tools/parse-openapi.d.ts +3 -0
  48. package/dist/tools/parse-openapi.d.ts.map +1 -0
  49. package/dist/tools/parse-openapi.js +48 -0
  50. package/dist/tools/parse-openapi.js.map +1 -0
  51. package/dist/tools/test-endpoint.d.ts +3 -0
  52. package/dist/tools/test-endpoint.d.ts.map +1 -0
  53. package/dist/tools/test-endpoint.js +66 -0
  54. package/dist/tools/test-endpoint.js.map +1 -0
  55. package/dist/tools/validate-response.d.ts +3 -0
  56. package/dist/tools/validate-response.d.ts.map +1 -0
  57. package/dist/tools/validate-response.js +44 -0
  58. package/dist/tools/validate-response.js.map +1 -0
  59. package/dist/types.d.ts +121 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +3 -0
  62. package/dist/types.js.map +1 -0
  63. package/evals/eval-http.ts +163 -0
  64. package/evals/eval-openapi.ts +506 -0
  65. package/evals/run-evals.ts +29 -0
  66. package/glama.json +4 -0
  67. package/package.json +37 -0
  68. package/smithery.yaml +9 -0
  69. package/src/index.ts +110 -0
  70. package/src/lib/code-generator.ts +1045 -0
  71. package/src/lib/http-client.ts +87 -0
  72. package/src/lib/license.ts +121 -0
  73. package/src/lib/openapi-parser.ts +456 -0
  74. package/src/lib/schema-validator.ts +234 -0
  75. package/src/tools/compare-specs.ts +67 -0
  76. package/src/tools/generate-client.ts +75 -0
  77. package/src/tools/generate-openapi.ts +67 -0
  78. package/src/tools/generate-tests.ts +69 -0
  79. package/src/tools/mock-server.ts +68 -0
  80. package/src/tools/parse-openapi.ts +54 -0
  81. package/src/tools/test-endpoint.ts +71 -0
  82. package/src/tools/validate-response.ts +54 -0
  83. package/src/types.ts +156 -0
  84. package/tsconfig.json +19 -0
@@ -0,0 +1,87 @@
1
+ import type { HttpRequest, HttpResponse } from '../types.js';
2
+
3
+ const DEFAULT_TIMEOUT_MS = 30_000;
4
+ const MAX_TIMEOUT_MS = 120_000;
5
+ const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
6
+
7
+ /**
8
+ * Execute an HTTP request using native fetch() and return structured results.
9
+ */
10
+ export async function executeRequest(req: HttpRequest): Promise<HttpResponse> {
11
+ const timeout = Math.min(req.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
12
+
13
+ const headers: Record<string, string> = { ...req.headers };
14
+ let bodyPayload: string | undefined;
15
+
16
+ if (req.body !== undefined && req.body !== null) {
17
+ if (typeof req.body === 'string') {
18
+ bodyPayload = req.body;
19
+ } else {
20
+ bodyPayload = JSON.stringify(req.body);
21
+ if (!headers['Content-Type'] && !headers['content-type']) {
22
+ headers['Content-Type'] = 'application/json';
23
+ }
24
+ }
25
+ }
26
+
27
+ const start = performance.now();
28
+
29
+ const response = await fetch(req.url, {
30
+ method: req.method,
31
+ headers,
32
+ body: bodyPayload,
33
+ signal: AbortSignal.timeout(timeout),
34
+ redirect: 'follow',
35
+ });
36
+
37
+ const timing_ms = Math.round(performance.now() - start);
38
+
39
+ // Read response body
40
+ const contentType = response.headers.get('content-type') ?? '';
41
+ const contentLength = response.headers.get('content-length');
42
+
43
+ if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_SIZE) {
44
+ throw new Error(`Response too large: ${contentLength} bytes (max ${MAX_RESPONSE_SIZE})`);
45
+ }
46
+
47
+ let body: unknown;
48
+ let size_bytes: number;
49
+
50
+ if (contentType.includes('application/json')) {
51
+ const text = await response.text();
52
+ size_bytes = new TextEncoder().encode(text).length;
53
+ try {
54
+ body = JSON.parse(text);
55
+ } catch {
56
+ body = text;
57
+ }
58
+ } else if (
59
+ contentType.includes('text/') ||
60
+ contentType.includes('application/xml') ||
61
+ contentType.includes('application/yaml') ||
62
+ contentType.includes('application/javascript')
63
+ ) {
64
+ const text = await response.text();
65
+ size_bytes = new TextEncoder().encode(text).length;
66
+ body = text;
67
+ } else {
68
+ const buffer = await response.arrayBuffer();
69
+ size_bytes = buffer.byteLength;
70
+ body = `<binary data: ${size_bytes} bytes, content-type: ${contentType}>`;
71
+ }
72
+
73
+ // Collect response headers
74
+ const responseHeaders: Record<string, string> = {};
75
+ response.headers.forEach((value, key) => {
76
+ responseHeaders[key] = value;
77
+ });
78
+
79
+ return {
80
+ status: response.status,
81
+ statusText: response.statusText,
82
+ headers: responseHeaders,
83
+ body,
84
+ timing_ms,
85
+ size_bytes,
86
+ };
87
+ }
@@ -0,0 +1,121 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import type { LicenseStatus, LicenseTier } from '../types.js';
5
+
6
+ const DATA_DIR = join(homedir(), '.truss');
7
+ const CACHE_FILE = 'api-mcp-license-cache.json';
8
+ const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
9
+ const API_BASE_URL = process.env.TRUSS_API_BASE_URL || 'https://api.truss.dev';
10
+
11
+ // Ensure data directory exists
12
+ try {
13
+ mkdirSync(DATA_DIR, { recursive: true });
14
+ } catch {
15
+ // ignore
16
+ }
17
+
18
+ interface LicenseCache {
19
+ key: string;
20
+ valid: boolean;
21
+ tier: LicenseTier;
22
+ expiresAt: string | null;
23
+ cachedAt: number;
24
+ }
25
+
26
+ function getCachePath(): string {
27
+ return join(DATA_DIR, CACHE_FILE);
28
+ }
29
+
30
+ function readCache(): LicenseCache | null {
31
+ try {
32
+ const raw = readFileSync(getCachePath(), 'utf-8');
33
+ const cache = JSON.parse(raw) as LicenseCache;
34
+ if (Date.now() - cache.cachedAt < CACHE_TTL_MS) {
35
+ return cache;
36
+ }
37
+ return null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function writeCache(cache: LicenseCache): void {
44
+ try {
45
+ writeFileSync(getCachePath(), JSON.stringify(cache, null, 2));
46
+ } catch {
47
+ // non-fatal
48
+ }
49
+ }
50
+
51
+ function isValidKeyFormat(key: string): boolean {
52
+ return /^truss_[0-9a-f]{32}$/.test(key);
53
+ }
54
+
55
+ async function validateRemote(key: string): Promise<{ valid: boolean; expiresAt: string | null }> {
56
+ const url = `${API_BASE_URL}/validate/${key}`;
57
+
58
+ try {
59
+ const response = await fetch(url, {
60
+ method: 'GET',
61
+ headers: {
62
+ 'Accept': 'application/json',
63
+ 'User-Agent': '@truss-dev/api-testing-mcp/1.0.0',
64
+ },
65
+ signal: AbortSignal.timeout(5000),
66
+ });
67
+
68
+ if (!response.ok) {
69
+ return { valid: false, expiresAt: null };
70
+ }
71
+
72
+ const body = await response.json() as { valid: boolean; expires_at?: string };
73
+ return {
74
+ valid: body.valid === true,
75
+ expiresAt: body.expires_at ?? null,
76
+ };
77
+ } catch {
78
+ // Network error — fall back to format check
79
+ return { valid: isValidKeyFormat(key), expiresAt: null };
80
+ }
81
+ }
82
+
83
+ export async function getLicenseStatus(): Promise<LicenseStatus> {
84
+ const key = process.env.TRUSS_LICENSE_KEY;
85
+
86
+ if (!key) {
87
+ return { tier: 'free', valid: true, expiresAt: null };
88
+ }
89
+
90
+ if (!isValidKeyFormat(key)) {
91
+ return { tier: 'free', valid: false, expiresAt: null };
92
+ }
93
+
94
+ const cached = readCache();
95
+ if (cached && cached.key === key) {
96
+ return { tier: cached.tier, valid: cached.valid, expiresAt: cached.expiresAt };
97
+ }
98
+
99
+ const result = await validateRemote(key);
100
+ const tier: LicenseTier = result.valid ? 'pro' : 'free';
101
+
102
+ writeCache({
103
+ key,
104
+ valid: result.valid,
105
+ tier,
106
+ expiresAt: result.expiresAt,
107
+ cachedAt: Date.now(),
108
+ });
109
+
110
+ return { tier, valid: result.valid, expiresAt: result.expiresAt };
111
+ }
112
+
113
+ export async function requirePro(): Promise<void> {
114
+ const status = await getLicenseStatus();
115
+ if (status.tier !== 'pro') {
116
+ throw new Error(
117
+ 'This feature requires a TRUSS Pro license ($25/mo). ' +
118
+ 'Get yours at https://truss.dev/pricing and set TRUSS_LICENSE_KEY env var.'
119
+ );
120
+ }
121
+ }
@@ -0,0 +1,456 @@
1
+ import * as yaml from 'js-yaml';
2
+ import type {
3
+ ParsedOpenApiSpec,
4
+ OpenApiEndpoint,
5
+ OpenApiParameter,
6
+ OpenApiResponse,
7
+ JsonSchema,
8
+ OpenApiInfo,
9
+ } from '../types.js';
10
+
11
+ const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'] as const;
12
+
13
+ /**
14
+ * Parse an OpenAPI 3.x or Swagger 2.0 spec from JSON or YAML string.
15
+ */
16
+ export function parseOpenApiSpec(input: string): ParsedOpenApiSpec {
17
+ let doc: Record<string, unknown>;
18
+
19
+ // Try JSON first, then YAML
20
+ try {
21
+ doc = JSON.parse(input) as Record<string, unknown>;
22
+ } catch {
23
+ try {
24
+ doc = yaml.load(input) as Record<string, unknown>;
25
+ } catch (yamlErr) {
26
+ throw new Error(
27
+ `Failed to parse spec as JSON or YAML: ${yamlErr instanceof Error ? yamlErr.message : String(yamlErr)}`
28
+ );
29
+ }
30
+ }
31
+
32
+ if (!doc || typeof doc !== 'object') {
33
+ throw new Error('Spec must be a valid JSON or YAML object');
34
+ }
35
+
36
+ // Detect version
37
+ const isSwagger2 = typeof doc.swagger === 'string' && doc.swagger.startsWith('2.');
38
+ const isOpenApi3 = typeof doc.openapi === 'string' && doc.openapi.startsWith('3.');
39
+
40
+ if (!isSwagger2 && !isOpenApi3) {
41
+ throw new Error('Unsupported spec: must be OpenAPI 3.x or Swagger 2.0');
42
+ }
43
+
44
+ // Build a flat definitions map for $ref resolution
45
+ const definitions = buildDefinitions(doc);
46
+
47
+ const info = extractInfo(doc);
48
+ const servers = isOpenApi3 ? extractServers(doc) : extractSwagger2Servers(doc);
49
+ const endpoints = extractEndpoints(doc, definitions, isSwagger2);
50
+
51
+ return { info, servers, endpoints };
52
+ }
53
+
54
+ // ── Info ────────────────────────────────────────────────────────────
55
+
56
+ function extractInfo(doc: Record<string, unknown>): OpenApiInfo {
57
+ const info = (doc.info ?? {}) as Record<string, unknown>;
58
+ return {
59
+ title: String(info.title ?? 'Untitled API'),
60
+ version: String(info.version ?? '0.0.0'),
61
+ description: info.description ? String(info.description) : undefined,
62
+ };
63
+ }
64
+
65
+ // ── Servers ─────────────────────────────────────────────────────────
66
+
67
+ function extractServers(
68
+ doc: Record<string, unknown>
69
+ ): Array<{ url: string; description?: string }> | undefined {
70
+ const servers = doc.servers as Array<Record<string, unknown>> | undefined;
71
+ if (!Array.isArray(servers) || servers.length === 0) return undefined;
72
+ return servers.map((s) => ({
73
+ url: String(s.url ?? ''),
74
+ description: s.description ? String(s.description) : undefined,
75
+ }));
76
+ }
77
+
78
+ function extractSwagger2Servers(
79
+ doc: Record<string, unknown>
80
+ ): Array<{ url: string; description?: string }> | undefined {
81
+ const host = doc.host as string | undefined;
82
+ const basePath = (doc.basePath as string) ?? '';
83
+ const schemes = (doc.schemes as string[]) ?? ['https'];
84
+ if (!host) return undefined;
85
+ return schemes.map((scheme) => ({ url: `${scheme}://${host}${basePath}` }));
86
+ }
87
+
88
+ // ── Definitions / Components ────────────────────────────────────────
89
+
90
+ function buildDefinitions(doc: Record<string, unknown>): Map<string, JsonSchema> {
91
+ const defs = new Map<string, JsonSchema>();
92
+
93
+ // OpenAPI 3.x components/schemas
94
+ const components = doc.components as Record<string, unknown> | undefined;
95
+ if (components?.schemas && typeof components.schemas === 'object') {
96
+ for (const [name, schema] of Object.entries(components.schemas as Record<string, unknown>)) {
97
+ defs.set(`#/components/schemas/${name}`, schema as JsonSchema);
98
+ }
99
+ }
100
+
101
+ // Swagger 2.0 definitions
102
+ const definitions = doc.definitions as Record<string, unknown> | undefined;
103
+ if (definitions && typeof definitions === 'object') {
104
+ for (const [name, schema] of Object.entries(definitions)) {
105
+ defs.set(`#/definitions/${name}`, schema as JsonSchema);
106
+ }
107
+ }
108
+
109
+ return defs;
110
+ }
111
+
112
+ function resolveRef(schema: JsonSchema, defs: Map<string, JsonSchema>, depth = 0): JsonSchema {
113
+ if (depth > 20) return schema; // prevent infinite recursion
114
+ if (schema.$ref) {
115
+ const resolved = defs.get(schema.$ref);
116
+ if (resolved) return resolveRef(resolved, defs, depth + 1);
117
+ // Unresolvable ref — return a stub
118
+ return { type: 'object', description: `Unresolved: ${schema.$ref}` };
119
+ }
120
+ return schema;
121
+ }
122
+
123
+ // ── Endpoints ───────────────────────────────────────────────────────
124
+
125
+ function extractEndpoints(
126
+ doc: Record<string, unknown>,
127
+ defs: Map<string, JsonSchema>,
128
+ isSwagger2: boolean
129
+ ): OpenApiEndpoint[] {
130
+ const paths = doc.paths as Record<string, Record<string, unknown>> | undefined;
131
+ if (!paths) return [];
132
+
133
+ const endpoints: OpenApiEndpoint[] = [];
134
+
135
+ for (const [path, pathItem] of Object.entries(paths)) {
136
+ if (!pathItem || typeof pathItem !== 'object') continue;
137
+
138
+ // Path-level parameters
139
+ const pathParams = (pathItem.parameters ?? []) as unknown[];
140
+
141
+ for (const method of HTTP_METHODS) {
142
+ const operation = pathItem[method] as Record<string, unknown> | undefined;
143
+ if (!operation) continue;
144
+
145
+ const operationParams = (operation.parameters ?? []) as unknown[];
146
+ const allParams = [...(pathParams as Record<string, unknown>[]), ...(operationParams as Record<string, unknown>[])];
147
+
148
+ const parameters = extractParameters(allParams, defs, isSwagger2);
149
+ const requestBody = isSwagger2
150
+ ? extractSwagger2Body(allParams, defs)
151
+ : extractRequestBody(operation, defs);
152
+ const responses = extractResponses(operation, defs, isSwagger2);
153
+ const tags = Array.isArray(operation.tags) ? operation.tags.map(String) : undefined;
154
+
155
+ endpoints.push({
156
+ method: method.toUpperCase(),
157
+ path,
158
+ summary: String(operation.summary ?? operation.description ?? ''),
159
+ operationId: operation.operationId ? String(operation.operationId) : undefined,
160
+ parameters,
161
+ requestBody: requestBody ?? undefined,
162
+ responses,
163
+ tags,
164
+ });
165
+ }
166
+ }
167
+
168
+ return endpoints;
169
+ }
170
+
171
+ function extractParameters(
172
+ params: Record<string, unknown>[],
173
+ defs: Map<string, JsonSchema>,
174
+ isSwagger2: boolean
175
+ ): OpenApiParameter[] {
176
+ return params
177
+ .filter((p) => {
178
+ // Skip body params in Swagger 2 — handled separately
179
+ if (isSwagger2 && p.in === 'body') return false;
180
+ return p.in === 'query' || p.in === 'path' || p.in === 'header' || p.in === 'cookie';
181
+ })
182
+ .map((p) => {
183
+ let schema: JsonSchema | undefined;
184
+ if (p.schema) {
185
+ schema = resolveRef(p.schema as JsonSchema, defs);
186
+ } else if (isSwagger2 && p.type) {
187
+ schema = { type: String(p.type) };
188
+ }
189
+
190
+ return {
191
+ name: String(p.name ?? ''),
192
+ in: p.in as 'query' | 'path' | 'header' | 'cookie',
193
+ required: Boolean(p.required),
194
+ description: p.description ? String(p.description) : undefined,
195
+ schema,
196
+ };
197
+ });
198
+ }
199
+
200
+ function extractRequestBody(
201
+ operation: Record<string, unknown>,
202
+ defs: Map<string, JsonSchema>
203
+ ): JsonSchema | null {
204
+ const rb = operation.requestBody as Record<string, unknown> | undefined;
205
+ if (!rb) return null;
206
+
207
+ const content = rb.content as Record<string, Record<string, unknown>> | undefined;
208
+ if (!content) return null;
209
+
210
+ // Prefer application/json
211
+ const jsonContent = content['application/json'] ?? content[Object.keys(content)[0]];
212
+ if (!jsonContent?.schema) return null;
213
+
214
+ return resolveRef(jsonContent.schema as JsonSchema, defs);
215
+ }
216
+
217
+ function extractSwagger2Body(
218
+ params: Record<string, unknown>[],
219
+ defs: Map<string, JsonSchema>
220
+ ): JsonSchema | null {
221
+ const bodyParam = params.find((p) => p.in === 'body');
222
+ if (!bodyParam?.schema) return null;
223
+ return resolveRef(bodyParam.schema as JsonSchema, defs);
224
+ }
225
+
226
+ function extractResponses(
227
+ operation: Record<string, unknown>,
228
+ defs: Map<string, JsonSchema>,
229
+ isSwagger2: boolean
230
+ ): OpenApiResponse[] {
231
+ const responses = operation.responses as Record<string, Record<string, unknown>> | undefined;
232
+ if (!responses) return [];
233
+
234
+ return Object.entries(responses).map(([status, resp]) => {
235
+ let schema: JsonSchema | undefined;
236
+
237
+ if (isSwagger2) {
238
+ if (resp.schema) {
239
+ schema = resolveRef(resp.schema as JsonSchema, defs);
240
+ }
241
+ } else {
242
+ const content = resp.content as Record<string, Record<string, unknown>> | undefined;
243
+ if (content) {
244
+ const jsonContent = content['application/json'] ?? content[Object.keys(content)[0]];
245
+ if (jsonContent?.schema) {
246
+ schema = resolveRef(jsonContent.schema as JsonSchema, defs);
247
+ }
248
+ }
249
+ }
250
+
251
+ return {
252
+ status,
253
+ description: String(resp.description ?? ''),
254
+ schema,
255
+ };
256
+ });
257
+ }
258
+
259
+ // ── Generate OpenAPI from Examples ──────────────────────────────────
260
+
261
+ import type { RequestExample } from '../types.js';
262
+
263
+ /**
264
+ * Auto-generate an OpenAPI 3.0 spec from a set of request/response examples.
265
+ */
266
+ export function generateOpenApiFromExamples(examples: RequestExample[]): string {
267
+ // Group examples by path pattern
268
+ const endpointMap = new Map<string, RequestExample[]>();
269
+
270
+ for (const ex of examples) {
271
+ let urlPath: string;
272
+ try {
273
+ const parsed = new URL(ex.url);
274
+ urlPath = parsed.pathname;
275
+ } catch {
276
+ urlPath = ex.url;
277
+ }
278
+
279
+ // Normalize numeric segments to path params
280
+ const normalized = urlPath.replace(/\/(\d+)/g, '/{id}');
281
+ const key = `${ex.method.toUpperCase()} ${normalized}`;
282
+
283
+ if (!endpointMap.has(key)) endpointMap.set(key, []);
284
+ endpointMap.get(key)!.push(ex);
285
+ }
286
+
287
+ // Extract base URL from first example
288
+ let baseUrl = '';
289
+ try {
290
+ const parsed = new URL(examples[0].url);
291
+ baseUrl = `${parsed.protocol}//${parsed.host}`;
292
+ } catch {
293
+ baseUrl = 'http://localhost:3000';
294
+ }
295
+
296
+ // Build paths
297
+ const paths: Record<string, Record<string, unknown>> = {};
298
+
299
+ for (const [key, exs] of endpointMap) {
300
+ const [method, path] = key.split(' ', 2);
301
+ if (!paths[path]) paths[path] = {};
302
+
303
+ const example = exs[0];
304
+ const methodLower = method.toLowerCase();
305
+
306
+ // Infer path parameters
307
+ const pathParams: Record<string, unknown>[] = [];
308
+ const paramMatches = path.match(/\{(\w+)\}/g);
309
+ if (paramMatches) {
310
+ for (const match of paramMatches) {
311
+ const name = match.replace(/[{}]/g, '');
312
+ pathParams.push({
313
+ name,
314
+ in: 'path',
315
+ required: true,
316
+ schema: { type: 'integer' },
317
+ });
318
+ }
319
+ }
320
+
321
+ // Infer query parameters from URL
322
+ const queryParams: Record<string, unknown>[] = [];
323
+ try {
324
+ const parsed = new URL(example.url);
325
+ for (const [qname, qvalue] of parsed.searchParams) {
326
+ queryParams.push({
327
+ name: qname,
328
+ in: 'query',
329
+ required: false,
330
+ schema: { type: inferJsonType(qvalue) },
331
+ example: qvalue,
332
+ });
333
+ }
334
+ } catch {
335
+ // ignore
336
+ }
337
+
338
+ const operation: Record<string, unknown> = {
339
+ summary: `${method} ${path}`,
340
+ operationId: `${methodLower}${path.replace(/[/{}\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`,
341
+ parameters: [...pathParams, ...queryParams],
342
+ responses: {},
343
+ };
344
+
345
+ // Request body
346
+ if (example.request_body !== undefined && example.request_body !== null) {
347
+ operation.requestBody = {
348
+ required: true,
349
+ content: {
350
+ 'application/json': {
351
+ schema: inferSchema(example.request_body),
352
+ example: example.request_body,
353
+ },
354
+ },
355
+ };
356
+ }
357
+
358
+ // Responses — collect all unique status codes
359
+ const statusCodes = new Set(exs.map((e) => e.status));
360
+ const responses: Record<string, unknown> = {};
361
+
362
+ for (const status of statusCodes) {
363
+ const statusExample = exs.find((e) => e.status === status);
364
+ responses[String(status)] = {
365
+ description: describeStatus(status),
366
+ content: statusExample?.response_body !== undefined
367
+ ? {
368
+ 'application/json': {
369
+ schema: inferSchema(statusExample.response_body),
370
+ example: statusExample.response_body,
371
+ },
372
+ }
373
+ : undefined,
374
+ };
375
+ }
376
+
377
+ operation.responses = responses;
378
+ paths[path][methodLower] = operation;
379
+ }
380
+
381
+ const spec = {
382
+ openapi: '3.0.3',
383
+ info: {
384
+ title: 'Auto-Generated API',
385
+ version: '1.0.0',
386
+ description: `Generated from ${examples.length} example request(s)`,
387
+ },
388
+ servers: [{ url: baseUrl }],
389
+ paths,
390
+ };
391
+
392
+ return yaml.dump(spec, { lineWidth: 120, noRefs: true, sortKeys: false });
393
+ }
394
+
395
+ function inferJsonType(value: unknown): string {
396
+ if (value === null || value === undefined) return 'string';
397
+ if (typeof value === 'number' || (typeof value === 'string' && !isNaN(Number(value)) && value !== '')) {
398
+ return Number.isInteger(Number(value)) ? 'integer' : 'number';
399
+ }
400
+ if (typeof value === 'boolean' || value === 'true' || value === 'false') return 'boolean';
401
+ if (Array.isArray(value)) return 'array';
402
+ if (typeof value === 'object') return 'object';
403
+ return 'string';
404
+ }
405
+
406
+ function inferSchema(value: unknown): JsonSchema {
407
+ if (value === null || value === undefined) {
408
+ return { type: 'string', nullable: true };
409
+ }
410
+ if (typeof value === 'string') {
411
+ const schema: JsonSchema = { type: 'string' };
412
+ if (/^\d{4}-\d{2}-\d{2}/.test(value)) schema.format = 'date-time';
413
+ if (/^[^@]+@[^@]+\.[^@]+$/.test(value)) schema.format = 'email';
414
+ if (/^https?:\/\//.test(value)) schema.format = 'uri';
415
+ return schema;
416
+ }
417
+ if (typeof value === 'number') {
418
+ return Number.isInteger(value) ? { type: 'integer' } : { type: 'number' };
419
+ }
420
+ if (typeof value === 'boolean') {
421
+ return { type: 'boolean' };
422
+ }
423
+ if (Array.isArray(value)) {
424
+ return {
425
+ type: 'array',
426
+ items: value.length > 0 ? inferSchema(value[0]) : { type: 'string' },
427
+ };
428
+ }
429
+ if (typeof value === 'object') {
430
+ const properties: Record<string, JsonSchema> = {};
431
+ const required: string[] = [];
432
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
433
+ properties[k] = inferSchema(v);
434
+ if (v !== null && v !== undefined) required.push(k);
435
+ }
436
+ return { type: 'object', properties, required: required.length > 0 ? required : undefined };
437
+ }
438
+ return { type: 'string' };
439
+ }
440
+
441
+ function describeStatus(status: number): string {
442
+ const descriptions: Record<number, string> = {
443
+ 200: 'Successful response',
444
+ 201: 'Resource created',
445
+ 204: 'No content',
446
+ 400: 'Bad request',
447
+ 401: 'Unauthorized',
448
+ 403: 'Forbidden',
449
+ 404: 'Not found',
450
+ 409: 'Conflict',
451
+ 422: 'Unprocessable entity',
452
+ 429: 'Too many requests',
453
+ 500: 'Internal server error',
454
+ };
455
+ return descriptions[status] ?? `HTTP ${status}`;
456
+ }