diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9a53110..dc209dc 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -8,6 +8,8 @@ services: context: . dockerfile: packages/server/Dockerfile target: deps + extra_hosts: + - "host.docker.internal:host-gateway" command: > sh -c " npx prisma generate --schema=prisma/schema.prisma && diff --git a/packages/server/src/services/openapi-parser.ts b/packages/server/src/services/openapi-parser.ts index 533603f..cfdc00e 100644 --- a/packages/server/src/services/openapi-parser.ts +++ b/packages/server/src/services/openapi-parser.ts @@ -1,7 +1,8 @@ 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 SwaggerDoc = OpenAPIV2.Document; export type ParsedModule = { name: string; @@ -34,27 +35,55 @@ export type ParseResult = { endpoints: ParsedEndpoint[]; }; -export async function parseOpenApiDocument(input: string | object): Promise { - const rawApi = await SwaggerParser.validate(input as any); - const api = await SwaggerParser.dereference(rawApi as any) as OpenApiDoc; +function isSwagger2(api: OpenAPI.Document): api is SwaggerDoc { + return 'swagger' in api && (api as any).swagger?.startsWith('2.'); +} - 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; +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 paths = api.paths || {}; - const tagMap = new Map(); - if (api.tags) { - for (const tag of api.tags) { - tagMap.set(tag.name, tag.description || null); + 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)[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, + tags: endpointTags, + deprecated: operation.deprecated || false, + moduleName, + }); } } - const endpoints: ParsedEndpoint[] = []; - const usedTags = new Set(); + 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)) { if (!pathItem) continue; const methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] as const; @@ -63,11 +92,6 @@ export async function parseOpenApiDocument(input: string | object): Promise { + // 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(); + 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(); + 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 moduleNames = new Set(); diff --git a/packages/web/src/pages/ImportDialog.tsx b/packages/web/src/pages/ImportDialog.tsx index 271a111..674b909 100644 --- a/packages/web/src/pages/ImportDialog.tsx +++ b/packages/web/src/pages/ImportDialog.tsx @@ -31,14 +31,20 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) { setLoading(true); setError(''); try { - let body: Record; + let spec: unknown; + 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 { - try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; } + try { spec = JSON.parse(fileContent); } catch { spec = fileContent; } } + const data = await apiFetch('/projects', { - method: 'POST', body: JSON.stringify(body), + method: 'POST', body: JSON.stringify({ spec }), }); setResult(data); queryClient.invalidateQueries({ queryKey: ['projects'] });