dsh-plugin-yapi 0.0.0-stage → 1.4.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 +166 -2
- package/bin/yapi-doc.mjs +3 -0
- package/client.js +2464 -0
- package/cordis.patch.yml +9 -0
- package/index.js +554 -0
- package/lib/core.js +434 -0
- package/package.json +66 -4
- package/skills/yapi-doc/SKILL.md +68 -0
- package/skills/yapi-doc/yapi-doc.mjs +863 -0
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# dsh-plugin-yapi — bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# This MUST stay a plain insert: DSH's plugin market can hot-mount a bundle
|
|
4
|
+
# whose patch contains only insert rows (`- insert:` / `- id:` / `name:`).
|
|
5
|
+
# The YAPI account lives in ~/.dsh/dsh-plugin-yapi.json instead of here, so the
|
|
6
|
+
# patch carries no secret and the plugin can be enabled without an app restart.
|
|
7
|
+
- insert:
|
|
8
|
+
- id: yapi
|
|
9
|
+
name: 'dsh-plugin-yapi'
|
package/index.js
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-yapi — host half.
|
|
3
|
+
*
|
|
4
|
+
* Owns the YAPI account. The browser never sees the password: the host logs in
|
|
5
|
+
* lazily, keeps the `_yapi_token` / `_yapi_uid` cookie in memory, refreshes it
|
|
6
|
+
* on expiry, and exposes two exact `/api/yapi/*` routes to the panel:
|
|
7
|
+
*
|
|
8
|
+
* POST /api/yapi/interface { id } -> normalized interface document
|
|
9
|
+
* POST /api/yapi/search { projectId, keyword } -> fuzzy interface matches
|
|
10
|
+
* GET /api/yapi/health -> login / configuration status
|
|
11
|
+
* GET /api/yapi/settings -> current account (no password)
|
|
12
|
+
* POST /api/yapi/settings -> verify, then persist a new account
|
|
13
|
+
*
|
|
14
|
+
* Both routes sit on DSH's authenticated `/api` channel, so the panel talks to
|
|
15
|
+
* origin-relative URLs and the YAPI origin is never fetched from the browser.
|
|
16
|
+
*/
|
|
17
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { readFile } from 'node:fs/promises'
|
|
19
|
+
import { homedir } from 'node:os'
|
|
20
|
+
import { dirname, join } from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
normalizeBaseUrl,
|
|
25
|
+
normalizeInterface,
|
|
26
|
+
parseDocId,
|
|
27
|
+
rankInterfaces,
|
|
28
|
+
} from './lib/core.js'
|
|
29
|
+
|
|
30
|
+
export const name = 'yapi'
|
|
31
|
+
export const inject = ['connection', 'skills']
|
|
32
|
+
|
|
33
|
+
/** Directory of this host module; the packaged skill and CLI live below it. */
|
|
34
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
35
|
+
|
|
36
|
+
/** A failure with a stable machine-readable code for the panel's error box. */
|
|
37
|
+
class YapiError extends Error {
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} message - user-facing correction.
|
|
40
|
+
* @param {string} code - stable failure category.
|
|
41
|
+
*/
|
|
42
|
+
constructor(message, code) {
|
|
43
|
+
super(message)
|
|
44
|
+
this.name = 'YapiError'
|
|
45
|
+
this.code = code
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @param {unknown} error @returns {string} */
|
|
50
|
+
function describe(error) {
|
|
51
|
+
if (error instanceof Error) return error.message
|
|
52
|
+
return String(error ?? '未知错误')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* An abort signal bounded by the configured timeout, when the runtime has one.
|
|
57
|
+
* @param {number} milliseconds - request budget.
|
|
58
|
+
* @returns {AbortSignal | undefined} timeout signal or undefined.
|
|
59
|
+
*/
|
|
60
|
+
function timeoutSignal(milliseconds) {
|
|
61
|
+
try {
|
|
62
|
+
return AbortSignal.timeout(milliseconds)
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* JSON response with no caching and an explicit UTF-8 charset.
|
|
70
|
+
* @param {unknown} value - serializable body.
|
|
71
|
+
* @param {number} [status] - HTTP status.
|
|
72
|
+
* @returns {Response} the response the connection bridge forwards.
|
|
73
|
+
*/
|
|
74
|
+
function jsonResponse(value, status = 200) {
|
|
75
|
+
return new Response(JSON.stringify(value), {
|
|
76
|
+
status,
|
|
77
|
+
headers: {
|
|
78
|
+
'content-type': 'application/json; charset=utf-8',
|
|
79
|
+
'cache-control': 'no-store',
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Absolute path of the harness home used for the plugin's own config file. */
|
|
85
|
+
function harnessHome() {
|
|
86
|
+
const configured = typeof process !== 'undefined' ? process.env?.DSH_HOME : undefined
|
|
87
|
+
if (typeof configured === 'string' && configured.trim() !== '') return configured.trim()
|
|
88
|
+
return join(homedir(), '.dsh')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Location of the optional credential file written by the installer. */
|
|
92
|
+
export const CONFIG_FILE = join(harnessHome(), 'dsh-plugin-yapi.json')
|
|
93
|
+
|
|
94
|
+
const BUNDLED_SKILL_NAME = 'yapi-doc'
|
|
95
|
+
const BUNDLED_SKILL_PROVIDER = 'yapi-doc-plugin'
|
|
96
|
+
const BUNDLED_SKILL_DESCRIPTION =
|
|
97
|
+
'Read a YAPI interface document by ID, full URL, or path keyword. Use when a task needs an API method, path, headers, parameters, request body, response fields, or response example. The bundled CLI reads the local YAPI account and prints Markdown in one command.'
|
|
98
|
+
const BUNDLED_SKILL_WHEN =
|
|
99
|
+
'The user mentions YAPI, an interface document ID (for example 10001), an API path, or asks for the request or response fields of an API.'
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Register the packaged `yapi-doc` skill when the plugin was installed by
|
|
103
|
+
* `dsh plugin add` (no `~/.dsh/skills` copy present). The installer path still
|
|
104
|
+
* wins because the user skill root ranks above this bundled provider.
|
|
105
|
+
* @param {any} ctx - DSH host context (must carry the `skills` service).
|
|
106
|
+
*/
|
|
107
|
+
function registerBundledSkill(ctx) {
|
|
108
|
+
const skillDir = join(HERE, 'skills', 'yapi-doc')
|
|
109
|
+
const skillPath = join(skillDir, 'SKILL.md')
|
|
110
|
+
if (!existsSync(skillPath)) return
|
|
111
|
+
const userSkill = join(harnessHome(), 'skills', BUNDLED_SKILL_NAME, 'SKILL.md')
|
|
112
|
+
if (existsSync(userSkill)) return
|
|
113
|
+
|
|
114
|
+
ctx.skills.registerProvider(function () {
|
|
115
|
+
return {
|
|
116
|
+
name: BUNDLED_SKILL_PROVIDER,
|
|
117
|
+
async list() {
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
name: BUNDLED_SKILL_NAME,
|
|
121
|
+
description: BUNDLED_SKILL_DESCRIPTION,
|
|
122
|
+
whenToUse: BUNDLED_SKILL_WHEN,
|
|
123
|
+
source: 'bundled',
|
|
124
|
+
provider: BUNDLED_SKILL_PROVIDER,
|
|
125
|
+
rank: 1000,
|
|
126
|
+
path: skillPath,
|
|
127
|
+
},
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
async get(candidate) {
|
|
131
|
+
if (!candidate || candidate.name !== BUNDLED_SKILL_NAME) return undefined
|
|
132
|
+
const raw = await readFile(skillPath, 'utf8')
|
|
133
|
+
const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/u, '').trim()
|
|
134
|
+
const content = body.split('$HOME/.dsh/skills/yapi-doc').join(skillDir)
|
|
135
|
+
return {
|
|
136
|
+
name: BUNDLED_SKILL_NAME,
|
|
137
|
+
description: BUNDLED_SKILL_DESCRIPTION,
|
|
138
|
+
whenToUse: BUNDLED_SKILL_WHEN,
|
|
139
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
140
|
+
source: 'bundled',
|
|
141
|
+
provider: BUNDLED_SKILL_PROVIDER,
|
|
142
|
+
resourceBase: { kind: 'directory', path: skillDir },
|
|
143
|
+
path: skillPath,
|
|
144
|
+
content,
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Read the installer-written config file. Missing or malformed files are not a
|
|
153
|
+
* startup failure: the panel reports the missing credential when it first asks
|
|
154
|
+
* for a document, which keeps the plugin visible and explains the fix.
|
|
155
|
+
* @returns {Record<string, unknown>} parsed object, or an empty object.
|
|
156
|
+
*/
|
|
157
|
+
function readStoredConfig() {
|
|
158
|
+
try {
|
|
159
|
+
const parsed = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
|
|
160
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
161
|
+
} catch {
|
|
162
|
+
return {}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Atomically persist the account so the next start uses it. The temporary file
|
|
168
|
+
* is created with 0600 before the rename, so the password is never briefly
|
|
169
|
+
* readable by another user.
|
|
170
|
+
* @param {{ baseUrl: string, email: string, password: string, timeoutMs: number }} settings - verified account.
|
|
171
|
+
*/
|
|
172
|
+
function writeConfigFile(settings) {
|
|
173
|
+
const directory = dirname(CONFIG_FILE)
|
|
174
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
175
|
+
const temporary = `${CONFIG_FILE}.${String(process.pid)}.${String(Date.now())}.tmp`
|
|
176
|
+
const content = JSON.stringify(
|
|
177
|
+
{
|
|
178
|
+
baseUrl: settings.baseUrl,
|
|
179
|
+
email: settings.email,
|
|
180
|
+
password: settings.password,
|
|
181
|
+
timeoutMs: settings.timeoutMs,
|
|
182
|
+
},
|
|
183
|
+
null,
|
|
184
|
+
2,
|
|
185
|
+
)
|
|
186
|
+
writeFileSync(temporary, content + '\n', { mode: 0o600 })
|
|
187
|
+
chmodSync(temporary, 0o600)
|
|
188
|
+
renameSync(temporary, CONFIG_FILE)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resolve settings by precedence: plugin-row config → environment → config file.
|
|
193
|
+
* Keeping the row empty is what lets the market hot-mount the bundle patch
|
|
194
|
+
* (that path only accepts plain insert rows), while the file still carries the
|
|
195
|
+
* account for a fresh start.
|
|
196
|
+
* @param {Record<string, unknown>} config - loader-provided config.
|
|
197
|
+
* @returns {{ baseUrl: string, email: string, password: string, timeoutMs: number }} settings.
|
|
198
|
+
*/
|
|
199
|
+
function readConfig(config) {
|
|
200
|
+
const row = config && typeof config === 'object' ? config : {}
|
|
201
|
+
const env = typeof process !== 'undefined' && process.env ? process.env : {}
|
|
202
|
+
const stored = readStoredConfig()
|
|
203
|
+
const timeout = Number(row.timeoutMs ?? env.YAPI_TIMEOUT_MS ?? stored.timeoutMs)
|
|
204
|
+
return {
|
|
205
|
+
baseUrl: normalizeBaseUrl(row.baseUrl ?? env.YAPI_BASE_URL ?? stored.baseUrl ?? ''),
|
|
206
|
+
email: String(row.email ?? env.YAPI_EMAIL ?? stored.email ?? '').trim(),
|
|
207
|
+
password: String(row.password ?? env.YAPI_PASSWORD ?? stored.password ?? ''),
|
|
208
|
+
timeoutMs: Number.isFinite(timeout) && timeout >= 1000 ? Math.min(timeout, 120_000) : 20_000,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Create the in-memory YAPI session and its two JSON verbs.
|
|
214
|
+
* @param {{ baseUrl: string, email: string, password: string, timeoutMs: number }} settings - resolved config.
|
|
215
|
+
* @param {any} logger - DSH logger (optional methods).
|
|
216
|
+
* @returns {{ getJson: Function, ensureSession: Function, status: Function }} client face.
|
|
217
|
+
*/
|
|
218
|
+
function createYapiClient(settings, logger) {
|
|
219
|
+
const { baseUrl, email, password, timeoutMs } = settings
|
|
220
|
+
let cookie = ''
|
|
221
|
+
let expiresAt = 0
|
|
222
|
+
let pendingLogin = null
|
|
223
|
+
let user = null
|
|
224
|
+
let lastLoginAt = 0
|
|
225
|
+
|
|
226
|
+
async function login() {
|
|
227
|
+
if (baseUrl === '') {
|
|
228
|
+
throw new YapiError(
|
|
229
|
+
'未配置 YAPI 地址。请在面板「账号设置」填写,或设置 YAPI_BASE_URL。',
|
|
230
|
+
'NO_CONFIG',
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
if (email === '' || password === '') {
|
|
234
|
+
throw new YapiError(
|
|
235
|
+
`未配置 YAPI 账号。请编辑 ${CONFIG_FILE} 填写 baseUrl / email / password,或在面板「账号设置」中填写。`,
|
|
236
|
+
'NO_CREDENTIALS',
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
let response
|
|
240
|
+
try {
|
|
241
|
+
response = await fetch(`${baseUrl}/api/user/login`, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
244
|
+
body: JSON.stringify({ email, password }),
|
|
245
|
+
signal: timeoutSignal(timeoutMs),
|
|
246
|
+
})
|
|
247
|
+
} catch (error) {
|
|
248
|
+
throw new YapiError(`无法连接 YAPI(${baseUrl}):${describe(error)}`, 'NETWORK')
|
|
249
|
+
}
|
|
250
|
+
const text = await response.text()
|
|
251
|
+
let payload = null
|
|
252
|
+
try {
|
|
253
|
+
payload = JSON.parse(text)
|
|
254
|
+
} catch {
|
|
255
|
+
payload = null
|
|
256
|
+
}
|
|
257
|
+
if (!payload || typeof payload !== 'object') {
|
|
258
|
+
throw new YapiError(`YAPI 登录响应不是 JSON(HTTP ${response.status}),请检查地址是否正确。`, 'LOGIN_BAD_RESPONSE')
|
|
259
|
+
}
|
|
260
|
+
if (payload.errcode !== 0) {
|
|
261
|
+
throw new YapiError(`YAPI 登录失败:${payload.errmsg || `错误码 ${String(payload.errcode)}`}`, 'LOGIN_FAILED')
|
|
262
|
+
}
|
|
263
|
+
const lines = typeof response.headers.getSetCookie === 'function' ? response.headers.getSetCookie() : []
|
|
264
|
+
const pairs = []
|
|
265
|
+
for (const line of lines) {
|
|
266
|
+
const pair = String(line).split(';')[0].trim()
|
|
267
|
+
if (pair.startsWith('_yapi_')) pairs.push(pair)
|
|
268
|
+
}
|
|
269
|
+
if (pairs.length === 0) {
|
|
270
|
+
throw new YapiError('YAPI 登录成功,但响应没有返回 _yapi_token Cookie。', 'LOGIN_NO_COOKIE')
|
|
271
|
+
}
|
|
272
|
+
cookie = pairs.join('; ')
|
|
273
|
+
// YAPI's token lives for seven days; refresh a day early so a stale cookie
|
|
274
|
+
// never reaches a query.
|
|
275
|
+
expiresAt = Date.now() + 6 * 24 * 60 * 60 * 1000
|
|
276
|
+
user = payload.data && typeof payload.data === 'object' ? payload.data : null
|
|
277
|
+
lastLoginAt = Date.now()
|
|
278
|
+
logger?.info?.(`[yapi] 登录成功:${user?.email || email}`)
|
|
279
|
+
return cookie
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function ensureSession(force) {
|
|
283
|
+
if (!force && cookie !== '' && Date.now() < expiresAt) return cookie
|
|
284
|
+
if (pendingLogin === null) {
|
|
285
|
+
pendingLogin = login().finally(() => {
|
|
286
|
+
pendingLogin = null
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
return pendingLogin
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function getJson(pathname, options = {}) {
|
|
293
|
+
const retry = options.retry !== false
|
|
294
|
+
await ensureSession(false)
|
|
295
|
+
let response
|
|
296
|
+
try {
|
|
297
|
+
response = await fetch(`${baseUrl}${pathname}`, {
|
|
298
|
+
method: 'GET',
|
|
299
|
+
headers: { cookie, accept: 'application/json' },
|
|
300
|
+
redirect: 'manual',
|
|
301
|
+
signal: timeoutSignal(timeoutMs),
|
|
302
|
+
})
|
|
303
|
+
} catch (error) {
|
|
304
|
+
throw new YapiError(`无法连接 YAPI(${baseUrl}):${describe(error)}`, 'NETWORK')
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const retryAfterSessionLoss = async (code, message) => {
|
|
308
|
+
if (!retry) throw new YapiError(message, code)
|
|
309
|
+
cookie = ''
|
|
310
|
+
expiresAt = 0
|
|
311
|
+
await ensureSession(true)
|
|
312
|
+
return getJson(pathname, { retry: false })
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
316
|
+
return retryAfterSessionLoss('SESSION_LOST', 'YAPI 会话已失效,重新登录后仍被重定向。')
|
|
317
|
+
}
|
|
318
|
+
if (response.status === 401 || response.status === 403) {
|
|
319
|
+
return retryAfterSessionLoss('SESSION_LOST', `YAPI 拒绝访问(HTTP ${response.status}),请确认账号有该项目的权限。`)
|
|
320
|
+
}
|
|
321
|
+
const text = await response.text()
|
|
322
|
+
let payload = null
|
|
323
|
+
try {
|
|
324
|
+
payload = JSON.parse(text)
|
|
325
|
+
} catch {
|
|
326
|
+
payload = null
|
|
327
|
+
}
|
|
328
|
+
if (!payload || typeof payload !== 'object') {
|
|
329
|
+
return retryAfterSessionLoss('BAD_RESPONSE', 'YAPI 返回了非 JSON 响应,可能是登录已失效或服务异常。')
|
|
330
|
+
}
|
|
331
|
+
if (payload.errcode !== 0) {
|
|
332
|
+
const code = Number(payload.errcode)
|
|
333
|
+
if ([40011, 40012, 40013].includes(code)) {
|
|
334
|
+
return retryAfterSessionLoss('SESSION_LOST', 'YAPI 会话已失效。')
|
|
335
|
+
}
|
|
336
|
+
throw new YapiError(payload.errmsg || `YAPI 错误码 ${code}`, `YAPI_${code}`)
|
|
337
|
+
}
|
|
338
|
+
return payload.data
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
getJson,
|
|
343
|
+
ensureSession,
|
|
344
|
+
status: () => ({ baseUrl, loggedIn: cookie !== '', user, lastLoginAt }),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Install the YAPI routes on the profile's authenticated connection channel.
|
|
350
|
+
* @param {any} ctx - DSH host context.
|
|
351
|
+
* @param {Record<string, unknown>} [config] - plugin row config.
|
|
352
|
+
*/
|
|
353
|
+
export function apply(ctx, config = {}) {
|
|
354
|
+
registerBundledSkill(ctx)
|
|
355
|
+
// Mutable so POST /api/yapi/settings can swap the account without a restart.
|
|
356
|
+
const state = {
|
|
357
|
+
settings: readConfig(config),
|
|
358
|
+
client: null,
|
|
359
|
+
}
|
|
360
|
+
state.client = createYapiClient(state.settings, ctx.logger)
|
|
361
|
+
const projectCache = new Map()
|
|
362
|
+
const listCache = new Map()
|
|
363
|
+
|
|
364
|
+
/** Everything the panel may know about the account — never the password. */
|
|
365
|
+
function settingsView() {
|
|
366
|
+
const status = state.client.status()
|
|
367
|
+
return {
|
|
368
|
+
baseUrl: state.settings.baseUrl,
|
|
369
|
+
email: state.settings.email,
|
|
370
|
+
hasPassword: state.settings.password !== '',
|
|
371
|
+
loggedIn: status.loggedIn,
|
|
372
|
+
user: status.user ? { username: status.user.username ?? '', email: status.user.email ?? '' } : null,
|
|
373
|
+
configFile: CONFIG_FILE,
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function projectOf(projectId) {
|
|
378
|
+
const cached = projectCache.get(projectId)
|
|
379
|
+
if (cached && Date.now() - cached.at < 5 * 60 * 1000) return cached.data
|
|
380
|
+
const data = await state.client.getJson(`/api/project/get?id=${encodeURIComponent(projectId)}`)
|
|
381
|
+
projectCache.set(projectId, { at: Date.now(), data })
|
|
382
|
+
return data
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function interfacesOf(projectId) {
|
|
386
|
+
const cached = listCache.get(projectId)
|
|
387
|
+
if (cached && Date.now() - cached.at < 5 * 60 * 1000) return cached.list
|
|
388
|
+
const data = await state.client.getJson(
|
|
389
|
+
`/api/interface/list?project_id=${encodeURIComponent(projectId)}&page=1&limit=2000`,
|
|
390
|
+
)
|
|
391
|
+
const list = Array.isArray(data && data.list) ? data.list : []
|
|
392
|
+
listCache.set(projectId, { at: Date.now(), list })
|
|
393
|
+
return list
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function handleSearch(request) {
|
|
397
|
+
let body = null
|
|
398
|
+
try {
|
|
399
|
+
body = await request.json()
|
|
400
|
+
} catch {
|
|
401
|
+
return jsonResponse({ ok: false, error: { code: 'BAD_REQUEST', message: '请求体必须是 JSON。' } }, 400)
|
|
402
|
+
}
|
|
403
|
+
const projectId = Number(body && body.projectId)
|
|
404
|
+
if (!Number.isSafeInteger(projectId) || projectId <= 0) {
|
|
405
|
+
return jsonResponse(
|
|
406
|
+
{ ok: false, error: { code: 'BAD_PROJECT', message: '请输入有效的项目 ID。' } },
|
|
407
|
+
400,
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
const keyword = String((body && body.keyword) ?? '').trim()
|
|
411
|
+
if (keyword === '') {
|
|
412
|
+
return jsonResponse({ ok: true, data: { projectId, projectName: '', total: 0, items: [] } })
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
const list = await interfacesOf(projectId)
|
|
416
|
+
let projectName = ''
|
|
417
|
+
try {
|
|
418
|
+
const project = await projectOf(projectId)
|
|
419
|
+
projectName = String((project && project.name) ?? '')
|
|
420
|
+
} catch (error) {
|
|
421
|
+
ctx.logger?.warn?.(`[yapi] 搜索时读取项目 ${projectId} 失败:${describe(error)}`)
|
|
422
|
+
}
|
|
423
|
+
const items = rankInterfaces(list, keyword, 30).map((item) => ({
|
|
424
|
+
id: Number(item._id),
|
|
425
|
+
method: String(item.method ?? '').toUpperCase(),
|
|
426
|
+
path: String(item.path ?? ''),
|
|
427
|
+
title: String(item.title ?? ''),
|
|
428
|
+
}))
|
|
429
|
+
return jsonResponse({ ok: true, data: { projectId, projectName, total: list.length, items } })
|
|
430
|
+
} catch (error) {
|
|
431
|
+
return jsonResponse({ ok: false, error: { code: error.code || 'UNKNOWN', message: describe(error) } }, 200)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function handleInterface(request) {
|
|
436
|
+
let body = null
|
|
437
|
+
try {
|
|
438
|
+
body = await request.json()
|
|
439
|
+
} catch {
|
|
440
|
+
return jsonResponse({ ok: false, error: { code: 'BAD_REQUEST', message: '请求体必须是 JSON:{"id": 10001}' } }, 400)
|
|
441
|
+
}
|
|
442
|
+
const id = parseDocId(body && typeof body === 'object' ? body.id : body)
|
|
443
|
+
if (id === null) {
|
|
444
|
+
return jsonResponse({ ok: false, error: { code: 'BAD_ID', message: '请输入有效的文档 ID(例如 10001)或接口链接。' } }, 400)
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const raw = await state.client.getJson(`/api/interface/get?id=${id}`)
|
|
448
|
+
if (!raw || typeof raw !== 'object' || Number(raw._id) <= 0) {
|
|
449
|
+
throw new YapiError(`YAPI 没有返回文档 ${id} 的数据,可能已被删除。`, 'NO_DATA')
|
|
450
|
+
}
|
|
451
|
+
let project = null
|
|
452
|
+
const projectId = Number(raw && raw.project_id)
|
|
453
|
+
if (Number.isSafeInteger(projectId) && projectId > 0) {
|
|
454
|
+
try {
|
|
455
|
+
project = await projectOf(projectId)
|
|
456
|
+
} catch (error) {
|
|
457
|
+
ctx.logger?.warn?.(`[yapi] 读取项目 ${projectId} 失败:${describe(error)}`)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return jsonResponse({ ok: true, data: normalizeInterface(raw, project, state.settings.baseUrl) })
|
|
461
|
+
} catch (error) {
|
|
462
|
+
return jsonResponse({ ok: false, error: { code: error.code || 'UNKNOWN', message: describe(error) } }, 200)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function handleHealth() {
|
|
467
|
+
try {
|
|
468
|
+
await state.client.ensureSession(false)
|
|
469
|
+
return jsonResponse({ ok: true, ...settingsView() })
|
|
470
|
+
} catch (error) {
|
|
471
|
+
return jsonResponse({ ok: false, error: { code: error.code || 'UNKNOWN', message: describe(error) } }, 200)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* GET returns the current account; POST verifies the submitted account with a
|
|
477
|
+
* real YAPI login and only then persists it. A failed login changes nothing,
|
|
478
|
+
* so a typo cannot lock the panel out of a working account.
|
|
479
|
+
*/
|
|
480
|
+
async function handleSettings(request) {
|
|
481
|
+
if (request.method === 'GET') {
|
|
482
|
+
return jsonResponse({ ok: true, ...settingsView() })
|
|
483
|
+
}
|
|
484
|
+
if (request.method !== 'POST') {
|
|
485
|
+
return jsonResponse({ ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: '仅支持 GET / POST。' } }, 405)
|
|
486
|
+
}
|
|
487
|
+
let body = null
|
|
488
|
+
try {
|
|
489
|
+
body = await request.json()
|
|
490
|
+
} catch {
|
|
491
|
+
return jsonResponse({ ok: false, error: { code: 'BAD_REQUEST', message: '请求体必须是 JSON。' } }, 400)
|
|
492
|
+
}
|
|
493
|
+
const current = state.settings
|
|
494
|
+
const stored = readStoredConfig()
|
|
495
|
+
const baseUrl = normalizeBaseUrl((body && body.baseUrl) || current.baseUrl || stored.baseUrl || '')
|
|
496
|
+
if (baseUrl === '') {
|
|
497
|
+
return jsonResponse(
|
|
498
|
+
{ ok: false, error: { code: 'NO_CONFIG', message: '请填写 YAPI 地址(例如 https://yapi.example.com)。' } },
|
|
499
|
+
200,
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
const email = String((body && body.email) ?? current.email ?? stored.email ?? '').trim()
|
|
503
|
+
const typedPassword = body && typeof body.password === 'string' ? body.password : ''
|
|
504
|
+
const password =
|
|
505
|
+
typedPassword !== '' ? typedPassword : current.password || String(stored.password ?? '')
|
|
506
|
+
if (email === '' || password === '') {
|
|
507
|
+
return jsonResponse(
|
|
508
|
+
{ ok: false, error: { code: 'NO_CREDENTIALS', message: '请填写账号和密码。' } },
|
|
509
|
+
200,
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const next = { baseUrl, email, password, timeoutMs: current.timeoutMs }
|
|
514
|
+
const probe = createYapiClient(next, ctx.logger)
|
|
515
|
+
try {
|
|
516
|
+
await probe.ensureSession(true)
|
|
517
|
+
} catch (error) {
|
|
518
|
+
return jsonResponse(
|
|
519
|
+
{ ok: false, error: { code: error.code || 'LOGIN_FAILED', message: describe(error) } },
|
|
520
|
+
200,
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
writeConfigFile(next)
|
|
525
|
+
} catch (error) {
|
|
526
|
+
return jsonResponse(
|
|
527
|
+
{
|
|
528
|
+
ok: false,
|
|
529
|
+
error: { code: 'SAVE_FAILED', message: `登录成功,但写入配置文件失败:${describe(error)}` },
|
|
530
|
+
},
|
|
531
|
+
200,
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
state.settings = next
|
|
536
|
+
state.client = probe
|
|
537
|
+
projectCache.clear()
|
|
538
|
+
listCache.clear()
|
|
539
|
+
const user = probe.status().user
|
|
540
|
+
ctx.logger?.info?.(`[yapi] 账号已更新:${user?.email || email}`)
|
|
541
|
+
return jsonResponse({ ok: true, ...settingsView() })
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
ctx.inject(['connection'], (scope) => {
|
|
545
|
+
const routes = [
|
|
546
|
+
{ path: '/api/yapi/interface', methods: ['POST'], requestBody: 'buffered', fetch: handleInterface },
|
|
547
|
+
{ path: '/api/yapi/search', methods: ['POST'], requestBody: 'buffered', fetch: handleSearch },
|
|
548
|
+
{ path: '/api/yapi/health', methods: ['GET'], requestBody: 'buffered', fetch: handleHealth },
|
|
549
|
+
{ path: '/api/yapi/settings', methods: ['GET', 'POST'], requestBody: 'buffered', fetch: handleSettings },
|
|
550
|
+
]
|
|
551
|
+
for (const route of routes) scope.connection.fetch.register(route)
|
|
552
|
+
ctx.logger?.info?.(`[yapi] 已注册 ${routes.length} 条路由:/api/yapi/{interface,search,health,settings};配置 ${CONFIG_FILE}`)
|
|
553
|
+
})
|
|
554
|
+
}
|