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:
@@ -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);
|
||||
|
||||
30
packages/server/src/routes/fetch-spec.ts
Normal file
30
packages/server/src/routes/fetch-spec.ts
Normal 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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: (
|
||||
|
||||
32
packages/web/src/lib/fetch-spec.ts
Normal file
32
packages/web/src/lib/fetch-spec.ts
Normal 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);
|
||||
}
|
||||
@@ -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 }; }
|
||||
}
|
||||
|
||||
@@ -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 }; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user