fix: fetch OpenAPI doc from browser to avoid Docker network isolation, add Swagger 2.0 support

This commit is contained in:
2026-04-02 14:51:43 +08:00
parent 5f76abec8b
commit 6aaba810d8
3 changed files with 94 additions and 24 deletions

View File

@@ -8,6 +8,8 @@ services:
context: . context: .
dockerfile: packages/server/Dockerfile dockerfile: packages/server/Dockerfile
target: deps target: deps
extra_hosts:
- "host.docker.internal:host-gateway"
command: > command: >
sh -c " sh -c "
npx prisma generate --schema=prisma/schema.prisma && npx prisma generate --schema=prisma/schema.prisma &&

View File

@@ -1,7 +1,8 @@
import SwaggerParser from '@apidevtools/swagger-parser'; import SwaggerParser from '@apidevtools/swagger-parser';
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'; import type { OpenAPI, OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
type OpenApiDoc = OpenAPIV3.Document | OpenAPIV3_1.Document; type OpenApiDoc = OpenAPIV3.Document | OpenAPIV3_1.Document;
type SwaggerDoc = OpenAPIV2.Document;
export type ParsedModule = { export type ParsedModule = {
name: string; name: string;
@@ -34,27 +35,55 @@ export type ParseResult = {
endpoints: ParsedEndpoint[]; endpoints: ParsedEndpoint[];
}; };
export async function parseOpenApiDocument(input: string | object): Promise<ParseResult> { function isSwagger2(api: OpenAPI.Document): api is SwaggerDoc {
const rawApi = await SwaggerParser.validate(input as any); return 'swagger' in api && (api as any).swagger?.startsWith('2.');
const api = await SwaggerParser.dereference(rawApi as any) as OpenApiDoc; }
const openApiVersion = 'openapi' in api ? api.openapi : 'unknown';
const name = api.info.title;
const description = api.info.description || null;
const version = api.info.version;
const baseUrl = api.servers?.[0]?.url || null;
const tagMap = new Map<string, string | null>();
if (api.tags) {
for (const tag of api.tags) {
tagMap.set(tag.name, tag.description || null);
}
}
function parseSwagger2Endpoints(api: SwaggerDoc): { endpoints: ParsedEndpoint[]; baseUrl: string | null } {
const baseUrl = api.basePath || (api.host ? `http://${api.host}${api.basePath || ''}` : null);
const endpoints: ParsedEndpoint[] = []; const endpoints: ParsedEndpoint[] = [];
const usedTags = new Set<string>();
const paths = api.paths || {}; const paths = api.paths || {};
for (const [pathStr, pathItem] of Object.entries(paths)) {
if (!pathItem) continue;
const methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] as const;
for (const method of methods) {
const operation = (pathItem as Record<string, unknown>)[method] as OpenAPIV2.OperationObject | undefined;
if (!operation) continue;
const endpointTags = operation.tags || [];
const prefix = pathStr.split('/').filter(Boolean)[0] || 'default';
const moduleName = endpointTags[0] || prefix;
// Convert Swagger 2 body parameter to requestBody-like structure
const params = (operation.parameters || []) as OpenAPIV2.Parameter[];
const bodyParam = params.find((p: any) => p.in === 'body');
const nonBodyParams = params.filter((p: any) => p.in !== 'body');
endpoints.push({
method: method.toUpperCase(),
path: pathStr,
summary: operation.summary || null,
description: operation.description || null,
operationId: operation.operationId || null,
parameters: nonBodyParams as unknown[],
requestBody: bodyParam ? { schema: (bodyParam as any).schema } : null,
responses: (operation.responses || {}) as Record<string, unknown>,
tags: endpointTags,
deprecated: operation.deprecated || false,
moduleName,
});
}
}
return { endpoints, baseUrl };
}
function parseOpenApi3Endpoints(api: OpenApiDoc): { endpoints: ParsedEndpoint[]; baseUrl: string | null } {
const baseUrl = api.servers?.[0]?.url || null;
const endpoints: ParsedEndpoint[] = [];
const paths = api.paths || {};
for (const [pathStr, pathItem] of Object.entries(paths)) { for (const [pathStr, pathItem] of Object.entries(paths)) {
if (!pathItem) continue; if (!pathItem) continue;
const methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] as const; const methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] as const;
@@ -63,11 +92,6 @@ export async function parseOpenApiDocument(input: string | object): Promise<Pars
if (!operation) continue; if (!operation) continue;
const endpointTags = operation.tags || []; const endpointTags = operation.tags || [];
for (const tag of endpointTags) {
usedTags.add(tag);
if (!tagMap.has(tag)) tagMap.set(tag, null);
}
const prefix = pathStr.split('/').filter(Boolean)[0] || 'default'; const prefix = pathStr.split('/').filter(Boolean)[0] || 'default';
const moduleName = endpointTags[0] || prefix; const moduleName = endpointTags[0] || prefix;
@@ -87,6 +111,44 @@ export async function parseOpenApiDocument(input: string | object): Promise<Pars
} }
} }
return { endpoints, baseUrl };
}
export async function parseOpenApiDocument(input: string | object): Promise<ParseResult> {
// Parse and dereference all $refs inline
const api = await SwaggerParser.dereference(input as any, {
dereference: { circular: 'ignore' },
}) as OpenAPI.Document;
const isV2 = isSwagger2(api);
const openApiVersion = isV2 ? (api as SwaggerDoc).swagger : ('openapi' in api ? (api as any).openapi : 'unknown');
const name = api.info.title;
const description = api.info.description || null;
const version = api.info.version;
// Collect tags
const tagMap = new Map<string, string | null>();
if (api.tags) {
for (const tag of api.tags) {
tagMap.set(tag.name, tag.description || null);
}
}
// Parse endpoints based on spec version
const { endpoints, baseUrl } = isV2
? parseSwagger2Endpoints(api as SwaggerDoc)
: parseOpenApi3Endpoints(api as OpenApiDoc);
// Track used tags
const usedTags = new Set<string>();
for (const ep of endpoints) {
for (const tag of ep.tags) {
usedTags.add(tag);
if (!tagMap.has(tag)) tagMap.set(tag, null);
}
}
// Build modules
const modules: ParsedModule[] = []; const modules: ParsedModule[] = [];
const moduleNames = new Set<string>(); const moduleNames = new Set<string>();

View File

@@ -31,14 +31,20 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
let body: Record<string, unknown>; let spec: unknown;
if (mode === 'url') { if (mode === 'url') {
body = { specUrl: url }; // Fetch from browser (can access local network) instead of letting server fetch
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch: ${res.status} ${res.statusText}`);
const text = await res.text();
try { spec = JSON.parse(text); } catch { spec = text; }
} else { } else {
try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; } try { spec = JSON.parse(fileContent); } catch { spec = fileContent; }
} }
const data = await apiFetch<ImportResult>('/projects', { const data = await apiFetch<ImportResult>('/projects', {
method: 'POST', body: JSON.stringify(body), method: 'POST', body: JSON.stringify({ spec }),
}); });
setResult(data); setResult(data);
queryClient.invalidateQueries({ queryKey: ['projects'] }); queryClient.invalidateQueries({ queryKey: ['projects'] });