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

@@ -10,6 +10,7 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
@@ -19,6 +20,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.96.1",
"js-yaml": "^4.1.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"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 { useI18n, type TranslationKey } from '../lib/i18n';
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',
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 { useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../lib/api';
import { fetchSpecFromUrl } from '../lib/fetch-spec';
import { useI18n } from '../lib/i18n';
import Modal from '../components/Modal';
@@ -44,7 +45,8 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
try {
let body: Record<string, unknown>;
if (mode === 'url') {
body = { specUrl: url };
const spec = await fetchSpecFromUrl(url);
body = { spec };
} else {
try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; }
}

View File

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