From f3fbd3876ab768dfbb1cf548241152e8fd76bdbb Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Fri, 3 Apr 2026 19:28:53 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20OpenAPI=20URL=20=E6=8A=93=E5=8F=96?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=89=8D=E7=AB=AF=E6=89=A7=E8=A1=8C=20+=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=20CORS=20=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端直接 fetch URL 支持 localhost/内网地址 - CORS 失败自动回退到服务端代理 /api/fetch-spec - 添加 js-yaml 支持 YAML 格式解析 - 服务端移除 specUrl 参数,只接收已解析的 spec 对象 Co-Authored-By: Claude Opus 4.6 --- packages/server/src/index.ts | 2 ++ packages/server/src/routes/fetch-spec.ts | 30 +++++++++++++++++ packages/server/src/routes/import.ts | 8 ++--- packages/server/src/routes/projects.ts | 8 ++--- .../server/src/services/openapi-parser.ts | 14 ++------ packages/web/package.json | 2 ++ packages/web/src/components/ThemeToggle.tsx | 4 +-- packages/web/src/lib/fetch-spec.ts | 32 +++++++++++++++++++ packages/web/src/pages/ImportDialog.tsx | 4 ++- packages/web/src/pages/ReimportDialog.tsx | 4 ++- pnpm-lock.yaml | 11 +++++++ 11 files changed, 95 insertions(+), 24 deletions(-) create mode 100644 packages/server/src/routes/fetch-spec.ts create mode 100644 packages/web/src/lib/fetch-spec.ts diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 31616d4..bdc5784 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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); diff --git a/packages/server/src/routes/fetch-spec.ts b/packages/server/src/routes/fetch-spec.ts new file mode 100644 index 0000000..7e3e320 --- /dev/null +++ b/packages/server/src/routes/fetch-spec.ts @@ -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; diff --git a/packages/server/src/routes/import.ts b/packages/server/src/routes/import.ts index c6a1c98..9d11148 100644 --- a/packages/server/src/routes/import.ts +++ b/packages/server/src/routes/import.ts @@ -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) => { diff --git a/packages/server/src/routes/projects.ts b/packages/server/src/routes/projects.ts index add4333..aede30e 100644 --- a/packages/server/src/routes/projects.ts +++ b/packages/server/src/routes/projects.ts @@ -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) => { diff --git a/packages/server/src/services/openapi-parser.ts b/packages/server/src/services/openapi-parser.ts index 50a85e4..b1d8dab 100644 --- a/packages/server/src/services/openapi-parser.ts +++ b/packages/server/src/services/openapi-parser.ts @@ -115,18 +115,8 @@ function parseOpenApi3Endpoints(api: OpenApiDoc): { endpoints: ParsedEndpoint[]; } export async function parseOpenApiDocument(input: string | object): Promise { - 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; diff --git a/packages/web/package.json b/packages/web/package.json index 6279047..2a82a98 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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" diff --git a/packages/web/src/components/ThemeToggle.tsx b/packages/web/src/components/ThemeToggle.tsx index 6c138b7..d6cce86 100644 --- a/packages/web/src/components/ThemeToggle.tsx +++ b/packages/web/src/components/ThemeToggle.tsx @@ -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: ( diff --git a/packages/web/src/lib/fetch-spec.ts b/packages/web/src/lib/fetch-spec.ts new file mode 100644 index 0000000..580588a --- /dev/null +++ b/packages/web/src/lib/fetch-spec.ts @@ -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 { + // 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); +} diff --git a/packages/web/src/pages/ImportDialog.tsx b/packages/web/src/pages/ImportDialog.tsx index 8b34f2e..617ff4e 100644 --- a/packages/web/src/pages/ImportDialog.tsx +++ b/packages/web/src/pages/ImportDialog.tsx @@ -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; if (mode === 'url') { - body = { specUrl: url }; + const spec = await fetchSpecFromUrl(url); + body = { spec }; } else { try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; } } diff --git a/packages/web/src/pages/ReimportDialog.tsx b/packages/web/src/pages/ReimportDialog.tsx index 9645833..14024a5 100644 --- a/packages/web/src/pages/ReimportDialog.tsx +++ b/packages/web/src/pages/ReimportDialog.tsx @@ -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; if (mode === 'url') { - body = { specUrl: url }; + const spec = await fetchSpecFromUrl(url); + body = { spec }; } else { try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 467c6fa..8eab01e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: '@tanstack/react-query': specifier: ^5.96.1 version: 5.96.1(react@19.2.4) + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 react: specifier: ^19.2.4 version: 19.2.4 @@ -130,6 +133,9 @@ importers: '@tailwindcss/vite': 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)) + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -633,6 +639,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1830,6 +1839,8 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/jsonwebtoken@9.0.10':