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.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/code-generator.d.ts +6 -0
- package/dist/lib/code-generator.d.ts.map +1 -0
- package/dist/lib/code-generator.js +890 -0
- package/dist/lib/code-generator.js.map +1 -0
- package/dist/lib/http-client.d.ts +6 -0
- package/dist/lib/http-client.d.ts.map +1 -0
- package/dist/lib/http-client.js +76 -0
- package/dist/lib/http-client.js.map +1 -0
- package/dist/lib/license.d.ts +4 -0
- package/dist/lib/license.d.ts.map +1 -0
- package/dist/lib/license.js +97 -0
- package/dist/lib/license.js.map +1 -0
- package/dist/lib/openapi-parser.d.ts +11 -0
- package/dist/lib/openapi-parser.d.ts.map +1 -0
- package/dist/lib/openapi-parser.js +390 -0
- package/dist/lib/openapi-parser.js.map +1 -0
- package/dist/lib/schema-validator.d.ts +15 -0
- package/dist/lib/schema-validator.d.ts.map +1 -0
- package/dist/lib/schema-validator.js +206 -0
- package/dist/lib/schema-validator.js.map +1 -0
- package/dist/tools/compare-specs.d.ts +3 -0
- package/dist/tools/compare-specs.d.ts.map +1 -0
- package/dist/tools/compare-specs.js +59 -0
- package/dist/tools/compare-specs.js.map +1 -0
- package/dist/tools/generate-client.d.ts +3 -0
- package/dist/tools/generate-client.d.ts.map +1 -0
- package/dist/tools/generate-client.js +65 -0
- package/dist/tools/generate-client.js.map +1 -0
- package/dist/tools/generate-openapi.d.ts +3 -0
- package/dist/tools/generate-openapi.d.ts.map +1 -0
- package/dist/tools/generate-openapi.js +57 -0
- package/dist/tools/generate-openapi.js.map +1 -0
- package/dist/tools/generate-tests.d.ts +3 -0
- package/dist/tools/generate-tests.d.ts.map +1 -0
- package/dist/tools/generate-tests.js +59 -0
- package/dist/tools/generate-tests.js.map +1 -0
- package/dist/tools/mock-server.d.ts +3 -0
- package/dist/tools/mock-server.d.ts.map +1 -0
- package/dist/tools/mock-server.js +60 -0
- package/dist/tools/mock-server.js.map +1 -0
- package/dist/tools/parse-openapi.d.ts +3 -0
- package/dist/tools/parse-openapi.d.ts.map +1 -0
- package/dist/tools/parse-openapi.js +48 -0
- package/dist/tools/parse-openapi.js.map +1 -0
- package/dist/tools/test-endpoint.d.ts +3 -0
- package/dist/tools/test-endpoint.d.ts.map +1 -0
- package/dist/tools/test-endpoint.js +66 -0
- package/dist/tools/test-endpoint.js.map +1 -0
- package/dist/tools/validate-response.d.ts +3 -0
- package/dist/tools/validate-response.d.ts.map +1 -0
- package/dist/tools/validate-response.js +44 -0
- package/dist/tools/validate-response.js.map +1 -0
- package/dist/types.d.ts +121 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/evals/eval-http.ts +163 -0
- package/evals/eval-openapi.ts +506 -0
- package/evals/run-evals.ts +29 -0
- package/glama.json +4 -0
- package/package.json +37 -0
- package/smithery.yaml +9 -0
- package/src/index.ts +110 -0
- package/src/lib/code-generator.ts +1045 -0
- package/src/lib/http-client.ts +87 -0
- package/src/lib/license.ts +121 -0
- package/src/lib/openapi-parser.ts +456 -0
- package/src/lib/schema-validator.ts +234 -0
- package/src/tools/compare-specs.ts +67 -0
- package/src/tools/generate-client.ts +75 -0
- package/src/tools/generate-openapi.ts +67 -0
- package/src/tools/generate-tests.ts +69 -0
- package/src/tools/mock-server.ts +68 -0
- package/src/tools/parse-openapi.ts +54 -0
- package/src/tools/test-endpoint.ts +71 -0
- package/src/tools/validate-response.ts +54 -0
- package/src/types.ts +156 -0
- 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
|
+
}
|