refactor: OpenAPI URL 抓取改为前端执行 + 服务端 CORS 代理

- 前端直接 fetch URL 支持 localhost/内网地址
- CORS 失败自动回退到服务端代理 /api/fetch-spec
- 添加 js-yaml 支持 YAML 格式解析
- 服务端移除 specUrl 参数,只接收已解析的 spec 对象

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 19:28:53 +08:00
parent 49ca1f6e1f
commit f3fbd3876a
11 changed files with 95 additions and 24 deletions

View File

@@ -6,6 +6,7 @@ import projectRouter from './routes/projects.js';
import importRouter from './routes/import.js';
import moduleRouter from './routes/modules.js';
import endpointRouter from './routes/endpoints.js';
import fetchSpecRouter from './routes/fetch-spec.js';
const app = express();
app.use(cors());
@@ -18,6 +19,7 @@ app.get('/api/health', (_req, res) => {
app.use('/api/auth', authRouter);
app.use('/api/auth/oauth', oauthRouter);
app.use('/api/fetch-spec', fetchSpecRouter);
app.use('/api/projects', projectRouter);
app.use('/api/projects', importRouter);
app.use('/api/projects', moduleRouter);

View File

@@ -0,0 +1,30 @@
import { Router, type Router as RouterType } from 'express';
import { requireAuth } from '../middleware/auth.js';
const router: RouterType = Router();
router.use(requireAuth);
// CORS proxy: frontend calls this when direct fetch is blocked by CORS
router.get('/', async (req, res) => {
const specUrl = req.query.url as string;
if (!specUrl || !specUrl.startsWith('http')) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide a valid URL' } });
return;
}
try {
const response = await fetch(specUrl, {
headers: { Accept: 'application/json, application/yaml, text/yaml, text/plain, */*' },
});
if (!response.ok) {
res.status(502).json({ success: false, error: { code: 'FETCH_FAILED', message: `Remote server returned ${response.status}` } });
return;
}
const text = await response.text();
res.json({ success: true, data: { content: text, contentType: response.headers.get('content-type') || '' } });
} catch (err) {
res.status(502).json({ success: false, error: { code: 'FETCH_FAILED', message: err instanceof Error ? err.message : 'Failed to fetch URL' } });
}
});
export default router;

View File

@@ -7,9 +7,9 @@ const router: RouterType = Router();
router.use(requireAuth);
router.post('/:id/reimport', async (req, res) => {
const { spec, specUrl } = req.body;
if (!spec && !specUrl) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec or specUrl' } });
const { spec } = req.body;
if (!spec) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec (JSON or YAML object)' } });
return;
}
@@ -22,7 +22,7 @@ router.post('/:id/reimport', async (req, res) => {
}
try {
const input = specUrl || spec;
const input = spec;
const parsed = await parseOpenApiDocument(input);
await prisma.$transaction(async (tx) => {

View File

@@ -8,14 +8,14 @@ const router: RouterType = Router();
router.use(requireAuth);
router.post('/', async (req, res) => {
const { spec, specUrl } = req.body;
if (!spec && !specUrl) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec (JSON object) or specUrl (URL string)' } });
const { spec } = req.body;
if (!spec) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec (JSON or YAML object)' } });
return;
}
try {
const input = specUrl || spec;
const input = spec;
const parsed = await parseOpenApiDocument(input);
const project = await prisma.$transaction(async (tx) => {

View File

@@ -115,18 +115,8 @@ function parseOpenApi3Endpoints(api: OpenApiDoc): { endpoints: ParsedEndpoint[];
}
export async function parseOpenApiDocument(input: string | object): Promise<ParseResult> {
let specInput: string | object = input;
// If input is a URL, fetch the content first so that swagger-parser
// works on a plain object and doesn't need network access for $ref resolution
if (typeof input === 'string' && input.startsWith('http')) {
const res = await fetch(input);
if (!res.ok) throw new Error(`Failed to fetch spec from URL: ${res.status} ${res.statusText}`);
specInput = await res.json() as object;
}
// Bundle resolves all $refs into a single document, then dereference inlines them
const bundled = await SwaggerParser.bundle(specInput as any) as OpenAPI.Document;
// SwaggerParser.bundle handles URLs, JSON objects, and YAML strings natively
const bundled = await SwaggerParser.bundle(input as any) as OpenAPI.Document;
const api = await SwaggerParser.dereference(bundled, {
dereference: { circular: 'ignore' },
}) as OpenAPI.Document;