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 importRouter from './routes/import.js';
import moduleRouter from './routes/modules.js'; import moduleRouter from './routes/modules.js';
import endpointRouter from './routes/endpoints.js'; import endpointRouter from './routes/endpoints.js';
import fetchSpecRouter from './routes/fetch-spec.js';
const app = express(); const app = express();
app.use(cors()); app.use(cors());
@@ -18,6 +19,7 @@ app.get('/api/health', (_req, res) => {
app.use('/api/auth', authRouter); app.use('/api/auth', authRouter);
app.use('/api/auth/oauth', oauthRouter); app.use('/api/auth/oauth', oauthRouter);
app.use('/api/fetch-spec', fetchSpecRouter);
app.use('/api/projects', projectRouter); app.use('/api/projects', projectRouter);
app.use('/api/projects', importRouter); app.use('/api/projects', importRouter);
app.use('/api/projects', moduleRouter); 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.use(requireAuth);
router.post('/:id/reimport', async (req, res) => { router.post('/:id/reimport', async (req, res) => {
const { spec, specUrl } = req.body; const { spec } = req.body;
if (!spec && !specUrl) { if (!spec) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec or specUrl' } }); res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec (JSON or YAML object)' } });
return; return;
} }
@@ -22,7 +22,7 @@ router.post('/:id/reimport', async (req, res) => {
} }
try { try {
const input = specUrl || spec; const input = spec;
const parsed = await parseOpenApiDocument(input); const parsed = await parseOpenApiDocument(input);
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {

View File

@@ -8,14 +8,14 @@ const router: RouterType = Router();
router.use(requireAuth); router.use(requireAuth);
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
const { spec, specUrl } = req.body; const { spec } = req.body;
if (!spec && !specUrl) { if (!spec) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec (JSON object) or specUrl (URL string)' } }); res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec (JSON or YAML object)' } });
return; return;
} }
try { try {
const input = specUrl || spec; const input = spec;
const parsed = await parseOpenApiDocument(input); const parsed = await parseOpenApiDocument(input);
const project = await prisma.$transaction(async (tx) => { 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> { export async function parseOpenApiDocument(input: string | object): Promise<ParseResult> {
let specInput: string | object = input; // SwaggerParser.bundle handles URLs, JSON objects, and YAML strings natively
const bundled = await SwaggerParser.bundle(input as any) as OpenAPI.Document;
// 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;
const api = await SwaggerParser.dereference(bundled, { const api = await SwaggerParser.dereference(bundled, {
dereference: { circular: 'ignore' }, dereference: { circular: 'ignore' },
}) as OpenAPI.Document; }) as OpenAPI.Document;

View File

@@ -10,6 +10,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
@@ -19,6 +20,7 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.96.1", "@tanstack/react-query": "^5.96.1",
"js-yaml": "^4.1.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router-dom": "^7.13.2" "react-router-dom": "^7.13.2"

View File

@@ -1,9 +1,9 @@
import { useState, useRef, useCallback } from 'react'; import { useState, useRef, useCallback, type ReactNode } from 'react';
import { useTheme } from '../lib/theme'; import { useTheme } from '../lib/theme';
import { useI18n, type TranslationKey } from '../lib/i18n'; import { useI18n, type TranslationKey } from '../lib/i18n';
import { useClickOutside } from '../hooks/useClickOutside'; import { useClickOutside } from '../hooks/useClickOutside';
const themes: Array<{ key: 'light' | 'dark' | 'system'; icon: JSX.Element }> = [ const themes: Array<{ key: 'light' | 'dark' | 'system'; icon: ReactNode }> = [
{ {
key: 'light', key: 'light',
icon: ( icon: (

View File

@@ -0,0 +1,32 @@
import yaml from 'js-yaml';
import { apiFetch } from './api';
function parseSpecText(text: string): object {
try {
return JSON.parse(text);
} catch {
return yaml.load(text) as object;
}
}
/**
* Fetch an OpenAPI spec from a URL and parse it.
* 1. Try direct fetch from browser (works for localhost/intranet)
* 2. If CORS blocks it, fall back to server-side proxy
* Returns a parsed spec object (JSON or YAML).
*/
export async function fetchSpecFromUrl(url: string): Promise<object> {
// Try direct fetch first (handles localhost, intranet, CORS-friendly APIs)
try {
const res = await fetch(url, {
headers: { Accept: 'application/json, application/yaml, text/yaml, */*' },
});
if (res.ok) return parseSpecText(await res.text());
} catch {
// CORS or network error — fall through to server proxy
}
// Fall back to server-side proxy for CORS-restricted URLs
const data = await apiFetch<{ content: string }>(`/fetch-spec?url=${encodeURIComponent(url)}`);
return parseSpecText(data.content);
}

View File

@@ -2,6 +2,7 @@ import { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
import { fetchSpecFromUrl } from '../lib/fetch-spec';
import { useI18n } from '../lib/i18n'; import { useI18n } from '../lib/i18n';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
@@ -44,7 +45,8 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
try { try {
let body: Record<string, unknown>; let body: Record<string, unknown>;
if (mode === 'url') { if (mode === 'url') {
body = { specUrl: url }; const spec = await fetchSpecFromUrl(url);
body = { spec };
} else { } else {
try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; } try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; }
} }

View File

@@ -1,5 +1,6 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
import { fetchSpecFromUrl } from '../lib/fetch-spec';
import { useI18n } from '../lib/i18n'; import { useI18n } from '../lib/i18n';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
@@ -49,7 +50,8 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
try { try {
let body: Record<string, unknown>; let body: Record<string, unknown>;
if (mode === 'url') { if (mode === 'url') {
body = { specUrl: url }; const spec = await fetchSpecFromUrl(url);
body = { spec };
} else { } else {
try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; } try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; }
} }

11
pnpm-lock.yaml generated
View File

@@ -117,6 +117,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.96.1 specifier: ^5.96.1
version: 5.96.1(react@19.2.4) version: 5.96.1(react@19.2.4)
js-yaml:
specifier: ^4.1.1
version: 4.1.1
react: react:
specifier: ^19.2.4 specifier: ^19.2.4
version: 19.2.4 version: 19.2.4
@@ -130,6 +133,9 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.5)(jiti@2.6.1)(tsx@4.21.0)) version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.5)(jiti@2.6.1)(tsx@4.21.0))
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
'@types/react': '@types/react':
specifier: ^19.2.14 specifier: ^19.2.14
version: 19.2.14 version: 19.2.14
@@ -633,6 +639,9 @@ packages:
'@types/http-errors@2.0.5': '@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -1830,6 +1839,8 @@ snapshots:
'@types/http-errors@2.0.5': {} '@types/http-errors@2.0.5': {}
'@types/js-yaml@4.0.9': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/jsonwebtoken@9.0.10': '@types/jsonwebtoken@9.0.10':