apiskill 0.1.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.
@@ -0,0 +1,262 @@
1
+ import { createServer } from 'node:http';
2
+
3
+ const METHODS = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'];
4
+ const DEFAULT_PORT = 4010;
5
+ const DEFAULT_HOST = '127.0.0.1';
6
+
7
+ export function hasMockableDocument(document) {
8
+ return Boolean(document?.paths && Object.keys(document.paths).length);
9
+ }
10
+
11
+ export function buildMockRoutes(document) {
12
+ const paths = document?.paths ?? {};
13
+ return Object.entries(paths)
14
+ .flatMap(([path, pathItem]) =>
15
+ METHODS.flatMap((method) => {
16
+ const operation = pathItem?.[method];
17
+ if (!operation) return [];
18
+ const response = selectResponse(operation);
19
+ return [
20
+ {
21
+ method: method.toUpperCase(),
22
+ path,
23
+ summary: operation.summary || operation.description || `${method.toUpperCase()} ${path}`,
24
+ status: response.status,
25
+ schema: response.schema,
26
+ regex: pathToRegex(path),
27
+ },
28
+ ];
29
+ }),
30
+ )
31
+ .sort((left, right) => routeRank(right.path) - routeRank(left.path));
32
+ }
33
+
34
+ export async function startMockServer({ document, meta, host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
35
+ if (!hasMockableDocument(document)) {
36
+ throw new Error('当前没有可用的 API 文档数据,请先导入、爬取或新建文档后再启动 MOCK 服务');
37
+ }
38
+
39
+ const routes = buildMockRoutes(document);
40
+ if (!routes.length) {
41
+ throw new Error('当前 API 文档没有可 MOCK 的接口路径');
42
+ }
43
+
44
+ const server = createServer(async (req, res) => {
45
+ handleRequest(req, res, { document, meta, routes });
46
+ });
47
+
48
+ await new Promise((resolve, reject) => {
49
+ server.once('error', reject);
50
+ server.listen(Number(port) || DEFAULT_PORT, host || DEFAULT_HOST, () => {
51
+ server.off('error', reject);
52
+ resolve();
53
+ });
54
+ });
55
+
56
+ const address = server.address();
57
+ const actualHost = typeof address === 'object' && address?.address ? address.address : host;
58
+ const actualPort = typeof address === 'object' && address?.port ? address.port : Number(port) || DEFAULT_PORT;
59
+
60
+ return {
61
+ server,
62
+ host: actualHost,
63
+ port: actualPort,
64
+ url: `http://${actualHost === '::' ? 'localhost' : actualHost}:${actualPort}`,
65
+ versionId: meta?.versionId || '',
66
+ title: meta?.title || document?.info?.title || '',
67
+ routesCount: routes.length,
68
+ routes: routes.map(({ method, path, summary, status }) => ({ method, path, summary, status })),
69
+ };
70
+ }
71
+
72
+ function handleRequest(req, res, context) {
73
+ setCorsHeaders(res);
74
+ if (req.method === 'OPTIONS') {
75
+ res.writeHead(204);
76
+ res.end();
77
+ return;
78
+ }
79
+
80
+ const url = new URL(req.url || '/', 'http://localhost');
81
+ if (url.pathname === '/__apiskill_mock') {
82
+ sendJson(res, 200, {
83
+ ok: true,
84
+ versionId: context.meta?.versionId || '',
85
+ title: context.meta?.title || context.document?.info?.title || '',
86
+ routes: context.routes.map(({ method, path, summary, status }) => ({ method, path, summary, status })),
87
+ });
88
+ return;
89
+ }
90
+
91
+ const method = String(req.method || 'GET').toUpperCase();
92
+ const route = context.routes.find((item) => item.method === method && item.regex.test(url.pathname));
93
+ if (!route) {
94
+ sendJson(res, 404, {
95
+ error: 'Mock route not found',
96
+ method,
97
+ path: url.pathname,
98
+ available: context.routes.slice(0, 20).map((item) => `${item.method} ${item.path}`),
99
+ });
100
+ return;
101
+ }
102
+
103
+ const body = route.schema
104
+ ? mockValue(route.schema, context.document, { name: route.path })
105
+ : {
106
+ code: 0,
107
+ message: 'mock success',
108
+ data: {
109
+ method: route.method,
110
+ path: route.path,
111
+ requestPath: url.pathname,
112
+ timestamp: new Date().toISOString(),
113
+ },
114
+ };
115
+
116
+ sendJson(res, route.status, body);
117
+ }
118
+
119
+ function selectResponse(operation) {
120
+ const responses = operation.responses ?? {};
121
+ const entries = Object.entries(responses);
122
+ const preferred =
123
+ entries.find(([status]) => /^2\d\d$/.test(status)) ??
124
+ entries.find(([status]) => status === 'default') ??
125
+ entries[0];
126
+ const status = Number(preferred?.[0]) || 200;
127
+ const response = preferred?.[1] ?? {};
128
+ const contentEntry =
129
+ Object.entries(response.content ?? {}).find(([contentType]) => contentType.includes('json')) ??
130
+ Object.entries(response.content ?? {})[0];
131
+ return {
132
+ status,
133
+ schema: contentEntry?.[1]?.schema ?? response.schema ?? response.responseSchema,
134
+ };
135
+ }
136
+
137
+ function mockValue(schema, document, options = {}) {
138
+ const depth = options.depth ?? 0;
139
+ if (!schema || depth > 8) return null;
140
+ const resolved = resolveSchema(document, schema, options.seen ?? new Set()) ?? schema;
141
+ const name = String(options.name || '').toLowerCase();
142
+
143
+ if (Array.isArray(resolved.enum) && resolved.enum.length) return randomItem(resolved.enum);
144
+ if (Array.isArray(resolved.oneOf) && resolved.oneOf.length) return mockValue(randomItem(resolved.oneOf), document, { ...options, depth: depth + 1 });
145
+ if (Array.isArray(resolved.anyOf) && resolved.anyOf.length) return mockValue(randomItem(resolved.anyOf), document, { ...options, depth: depth + 1 });
146
+ if (Array.isArray(resolved.allOf) && resolved.allOf.length) {
147
+ return resolved.allOf.reduce((merged, item) => {
148
+ const next = mockValue(item, document, { ...options, depth: depth + 1 });
149
+ return isPlainObject(next) ? { ...(isPlainObject(merged) ? merged : {}), ...next } : next;
150
+ }, {});
151
+ }
152
+
153
+ const type = inferType(resolved);
154
+ if (type === 'array') {
155
+ return [0, 1].map(() => mockValue(resolved.items || { type: 'object' }, document, { ...options, depth: depth + 1 }));
156
+ }
157
+ if (type === 'object') {
158
+ const properties = resolved.properties ?? {};
159
+ if (!Object.keys(properties).length) {
160
+ if (resolved.additionalProperties && typeof resolved.additionalProperties === 'object') {
161
+ return { [sampleKey(name)]: mockValue(resolved.additionalProperties, document, { ...options, depth: depth + 1 }) };
162
+ }
163
+ return {};
164
+ }
165
+ return Object.fromEntries(
166
+ Object.entries(properties).map(([key, property]) => [
167
+ key,
168
+ mockValue(property, document, {
169
+ ...options,
170
+ name: key,
171
+ depth: depth + 1,
172
+ }),
173
+ ]),
174
+ );
175
+ }
176
+ if (type === 'integer') return randomInteger(name);
177
+ if (type === 'number') return Number((Math.random() * 1000).toFixed(2));
178
+ if (type === 'boolean') return Math.random() > 0.5;
179
+ return randomString(name, resolved.format);
180
+ }
181
+
182
+ function resolveSchema(document, schema, seen) {
183
+ if (!schema || typeof schema !== 'object') return undefined;
184
+ if (!schema.$ref) return schema;
185
+ if (seen.has(schema.$ref)) return {};
186
+ seen.add(schema.$ref);
187
+ const value = schema.$ref.startsWith('#/')
188
+ ? schema.$ref
189
+ .slice(2)
190
+ .split('/')
191
+ .reduce((current, key) => (current && typeof current === 'object' ? current[decodeURIComponent(key)] : undefined), document)
192
+ : undefined;
193
+ return resolveSchema(document, value, seen) ?? value;
194
+ }
195
+
196
+ function inferType(schema) {
197
+ if (schema.type) return Array.isArray(schema.type) ? schema.type[0] : schema.type;
198
+ if (schema.properties || schema.additionalProperties) return 'object';
199
+ if (schema.items) return 'array';
200
+ return 'string';
201
+ }
202
+
203
+ function pathToRegex(path) {
204
+ const pattern = String(path || '/')
205
+ .replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
206
+ .replace(/\\\{[^/]+\\\}/g, '[^/]+')
207
+ .replace(/:([A-Za-z0-9_]+)/g, '[^/]+');
208
+ return new RegExp(`^${pattern}/?$`);
209
+ }
210
+
211
+ function routeRank(path) {
212
+ return String(path).split('/').filter(Boolean).filter((part) => !part.startsWith('{') && !part.startsWith(':')).length;
213
+ }
214
+
215
+ function sendJson(res, status, body) {
216
+ const text = JSON.stringify(body, null, 2);
217
+ res.writeHead(status, {
218
+ 'content-type': 'application/json; charset=utf-8',
219
+ 'content-length': Buffer.byteLength(text),
220
+ });
221
+ res.end(text);
222
+ }
223
+
224
+ function setCorsHeaders(res) {
225
+ res.setHeader('access-control-allow-origin', '*');
226
+ res.setHeader('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD');
227
+ res.setHeader('access-control-allow-headers', 'content-type,authorization,*');
228
+ }
229
+
230
+ function randomString(name, format) {
231
+ if (format === 'date-time') return new Date(Date.now() - randomInt(0, 1000000000)).toISOString();
232
+ if (format === 'date') return new Date(Date.now() - randomInt(0, 1000000000)).toISOString().slice(0, 10);
233
+ if (format === 'email' || name.includes('email')) return `user${randomInt(100, 999)}@example.com`;
234
+ if (format === 'uuid' || name.endsWith('id') || name.includes('uuid')) return `mock-${randomInt(100000, 999999)}`;
235
+ if (name.includes('phone') || name.includes('mobile')) return `138${randomInt(10000000, 99999999)}`;
236
+ if (name.includes('url')) return 'https://example.com/mock';
237
+ if (name.includes('name') || name.includes('title')) return `Mock ${randomInt(100, 999)}`;
238
+ if (name.includes('message') || name.includes('desc')) return 'Mock response generated by API Skill';
239
+ return `mock_${randomInt(1000, 9999)}`;
240
+ }
241
+
242
+ function randomInteger(name) {
243
+ if (name === 'code' || name.endsWith('code')) return 0;
244
+ if (name.endsWith('id') || name.includes('count') || name.includes('total')) return randomInt(1, 9999);
245
+ return randomInt(1, 100);
246
+ }
247
+
248
+ function sampleKey(name) {
249
+ return name ? `${name}Value` : 'mockKey';
250
+ }
251
+
252
+ function randomItem(items) {
253
+ return items[randomInt(0, items.length - 1)];
254
+ }
255
+
256
+ function randomInt(min, max) {
257
+ return Math.floor(Math.random() * (max - min + 1)) + min;
258
+ }
259
+
260
+ function isPlainObject(value) {
261
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
262
+ }
@@ -0,0 +1,169 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { parseOpenApiText, isOpenApiDocument } from './openapi-store.mjs';
3
+
4
+ export async function importFromUrl(url, options = {}) {
5
+ const sourceUrl = normalizeUrl(url);
6
+ const response = await fetchText(sourceUrl, options);
7
+ const document = parseOpenApiText(response.text);
8
+ if (!isOpenApiDocument(document)) throw new Error('该地址未返回有效的 OpenAPI/Swagger JSON 或 YAML 文档');
9
+ return { document, resolvedUrl: response.url || sourceUrl };
10
+ }
11
+
12
+ export async function importFromLocalFile(filePath) {
13
+ const text = await readFile(filePath, 'utf8');
14
+ const document = parseOpenApiText(text);
15
+ if (!isOpenApiDocument(document)) throw new Error('本地文件不是有效的 OpenAPI/Swagger JSON 或 YAML 文档');
16
+ return { document, resolvedUrl: filePath };
17
+ }
18
+
19
+ export async function importFromCurl(curlText) {
20
+ const parsed = parseCurl(curlText);
21
+ const response = await fetchText(parsed.url, { method: parsed.method, headers: parsed.headers, body: parsed.body });
22
+ const document = parseOpenApiText(response.text);
23
+ if (!isOpenApiDocument(document)) throw new Error('CURL 响应不是有效的 OpenAPI/Swagger JSON 或 YAML 文档');
24
+ return { document, resolvedUrl: response.url || parsed.url };
25
+ }
26
+
27
+ export async function crawlOpenApi(pageUrl, options = {}) {
28
+ const sourceUrl = normalizeUrl(pageUrl);
29
+ const direct = await tryOpenApi(sourceUrl, options);
30
+ if (direct) return direct;
31
+
32
+ const page = await fetchText(sourceUrl, { ...options, accept: 'text/html,application/xhtml+xml,application/json,*/*' });
33
+ const candidates = discoverOpenApiUrls(page.url || sourceUrl, page.text);
34
+ for (const candidate of candidates) {
35
+ const result = await tryOpenApi(candidate, { ...options, referer: page.url || sourceUrl });
36
+ if (result) return result;
37
+ }
38
+ throw new Error('未在页面中识别到可用的 OpenAPI/Swagger JSON 或 YAML 地址');
39
+ }
40
+
41
+ async function tryOpenApi(url, options) {
42
+ try {
43
+ const response = await fetchText(url, options);
44
+ const document = parseOpenApiText(response.text);
45
+ if (isOpenApiDocument(document)) return { document, resolvedUrl: response.url || url };
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ async function fetchText(url, options = {}) {
53
+ const headers = {
54
+ accept: options.accept || 'application/json,application/yaml,text/yaml,text/plain,*/*',
55
+ 'user-agent': options.userAgent || 'apiskill-cli/0.1.0',
56
+ ...(options.referer ? { referer: options.referer } : {}),
57
+ ...(options.headers ?? {}),
58
+ };
59
+ if (options.basicAuth) headers.authorization = `Basic ${Buffer.from(options.basicAuth, 'utf8').toString('base64')}`;
60
+ const response = await fetch(url, {
61
+ method: options.method || 'GET',
62
+ headers,
63
+ body: options.body,
64
+ redirect: 'follow',
65
+ });
66
+ const text = await response.text();
67
+ if (!response.ok) throw new Error(`请求失败 ${response.status}: ${url}`);
68
+ return { text, url: response.url };
69
+ }
70
+
71
+ function discoverOpenApiUrls(pageUrl, html) {
72
+ const urls = new Set();
73
+ const patterns = [
74
+ /spec-url\s*=\s*["'`]([^"'`]+)["'`]/gi,
75
+ /specUrl\s*[:=]\s*["'`]([^"'`]+)["'`]/g,
76
+ /url\s*:\s*["'`]([^"'`]+)["'`]/g,
77
+ /href=["']([^"']+(?:api-docs|swagger|openapi|api\.json|swagger\.json|\.ya?ml|\.json)[^"']*)["']/gi,
78
+ /src=["']([^"']+(?:api-docs|swagger|openapi|api\.json|swagger\.json|\.ya?ml|\.json)[^"']*)["']/gi,
79
+ /["'`]([^"'`]+(?:api-docs|swagger|openapi|api\.json|swagger\.json|\.ya?ml)[^"'`]*)["'`]/gi,
80
+ ];
81
+ for (const pattern of patterns) {
82
+ for (const match of html.matchAll(pattern)) addUrl(urls, pageUrl, match[1]);
83
+ }
84
+ [
85
+ '/api.json',
86
+ '/swagger.json',
87
+ '/openapi.json',
88
+ '/openapi.yaml',
89
+ '/swagger.yaml',
90
+ '/v2/api-docs',
91
+ '/v3/api-docs',
92
+ 'api.json',
93
+ 'swagger.json',
94
+ 'openapi.json',
95
+ 'openapi.yaml',
96
+ 'swagger.yaml',
97
+ ].forEach((candidate) => addUrl(urls, pageUrl, candidate));
98
+ return [...urls];
99
+ }
100
+
101
+ function addUrl(urls, base, value) {
102
+ if (!value || value.startsWith('data:') || value.startsWith('javascript:')) return;
103
+ try {
104
+ urls.add(new URL(value.replace(/\\u002F/g, '/').replace(/&/g, '&'), base).toString());
105
+ } catch {
106
+ // Ignore malformed discovery candidates.
107
+ }
108
+ }
109
+
110
+ function parseCurl(curlText) {
111
+ const tokens = tokenize(curlText.replace(/\\\r?\n/g, ' '));
112
+ if (tokens[0] !== 'curl') throw new Error('CURL 命令必须以 curl 开头');
113
+ const headers = {};
114
+ let method = 'GET';
115
+ let body;
116
+ let url = '';
117
+ for (let index = 1; index < tokens.length; index += 1) {
118
+ const token = tokens[index];
119
+ const next = () => tokens[++index];
120
+ if (token === '-H' || token === '--header') applyHeader(headers, next());
121
+ else if (token === '-X' || token === '--request') method = next().toUpperCase();
122
+ else if (token === '--url') url = next();
123
+ else if (['-d', '--data', '--data-raw', '--data-binary'].includes(token)) {
124
+ body = next();
125
+ if (method === 'GET') method = 'POST';
126
+ } else if (token === '-u' || token === '--user') headers.authorization = `Basic ${Buffer.from(next(), 'utf8').toString('base64')}`;
127
+ else if (!token.startsWith('-') && !url) url = token;
128
+ }
129
+ if (!url) throw new Error('CURL 命令中没有 URL');
130
+ return { url: normalizeUrl(url), method, headers, body };
131
+ }
132
+
133
+ function tokenize(value) {
134
+ const tokens = [];
135
+ let current = '';
136
+ let quote = '';
137
+ let escaping = false;
138
+ for (const char of value.trim()) {
139
+ if (escaping) {
140
+ current += char;
141
+ escaping = false;
142
+ } else if (char === '\\' && quote !== "'") {
143
+ escaping = true;
144
+ } else if ((char === '"' || char === "'") && !quote) {
145
+ quote = char;
146
+ } else if (char === quote) {
147
+ quote = '';
148
+ } else if (!quote && /\s/.test(char)) {
149
+ if (current) tokens.push(current);
150
+ current = '';
151
+ } else {
152
+ current += char;
153
+ }
154
+ }
155
+ if (current) tokens.push(current);
156
+ return tokens;
157
+ }
158
+
159
+ function applyHeader(headers, rawHeader = '') {
160
+ const index = rawHeader.indexOf(':');
161
+ if (index <= 0) return;
162
+ headers[rawHeader.slice(0, index).trim()] = rawHeader.slice(index + 1).trim();
163
+ }
164
+
165
+ function normalizeUrl(value) {
166
+ const url = new URL(String(value || '').trim());
167
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('仅支持 http 或 https 地址');
168
+ return url.toString();
169
+ }