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 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);
|
||||||
|
|||||||
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.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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
|||||||
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 { 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 }; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
11
pnpm-lock.yaml
generated
@@ -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':
|
||||||
|
|||||||
Reference in New Issue
Block a user