feat: add project CRUD, OpenAPI import/parsing, module and endpoint management routes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,10 +10,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agent-fox/shared": "workspace:*",
|
"@agent-fox/shared": "workspace:*",
|
||||||
|
"@apidevtools/swagger-parser": "^12.1.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.0.0",
|
"express": "^5.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"openapi-types": "^12.1.3",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import authRouter from './routes/auth.js';
|
import authRouter from './routes/auth.js';
|
||||||
|
import projectRouter from './routes/projects.js';
|
||||||
|
import importRouter from './routes/import.js';
|
||||||
|
import moduleRouter from './routes/modules.js';
|
||||||
|
import endpointRouter from './routes/endpoints.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -11,6 +15,10 @@ app.get('/api/health', (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use('/api/auth', authRouter);
|
app.use('/api/auth', authRouter);
|
||||||
|
app.use('/api/projects', projectRouter);
|
||||||
|
app.use('/api/projects', importRouter);
|
||||||
|
app.use('/api/projects', moduleRouter);
|
||||||
|
app.use('/api/projects', endpointRouter);
|
||||||
|
|
||||||
const port = process.env.SERVER_PORT || 3000;
|
const port = process.env.SERVER_PORT || 3000;
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
|
|||||||
14
packages/server/src/lib/api-key.ts
Normal file
14
packages/server/src/lib/api-key.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const PREFIX = 'afk_';
|
||||||
|
|
||||||
|
export function generateApiKey(): { raw: string; hash: string } {
|
||||||
|
const raw = PREFIX + randomBytes(24).toString('base64url');
|
||||||
|
const hash = bcrypt.hashSync(raw, 8);
|
||||||
|
return { raw, hash };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyApiKey(raw: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(raw, hash);
|
||||||
|
}
|
||||||
86
packages/server/src/routes/endpoints.ts
Normal file
86
packages/server/src/routes/endpoints.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Router, type Router as RouterType } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '@agent-fox/shared';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router: RouterType = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
router.get('/:id/endpoints', async (req, res) => {
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: { id: req.params.id, userId: req.user!.userId },
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { moduleId } = req.query;
|
||||||
|
const where: any = { projectId: req.params.id };
|
||||||
|
if (moduleId) where.moduleId = moduleId;
|
||||||
|
|
||||||
|
const endpoints = await prisma.endpoint.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true, method: true, path: true, summary: true,
|
||||||
|
deprecated: true, moduleId: true, module: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ path: 'asc' }, { method: 'asc' }],
|
||||||
|
});
|
||||||
|
res.json({ success: true, data: endpoints });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id/endpoints/:eid', async (req, res) => {
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: { id: req.params.id, userId: req.user!.userId },
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const endpoint = await prisma.endpoint.findFirst({
|
||||||
|
where: { id: req.params.eid, projectId: req.params.id },
|
||||||
|
include: { module: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!endpoint) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Endpoint not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: endpoint });
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveEndpointSchema = z.object({
|
||||||
|
moduleId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/:id/endpoints/:eid', async (req, res) => {
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: { id: req.params.id, userId: req.user!.userId },
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = moveEndpointSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetModule = await prisma.module.findFirst({
|
||||||
|
where: { id: parsed.data.moduleId, projectId: req.params.id },
|
||||||
|
});
|
||||||
|
if (!targetModule) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Target module not found in this project' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await prisma.endpoint.updateMany({
|
||||||
|
where: { id: req.params.eid, projectId: req.params.id },
|
||||||
|
data: { moduleId: parsed.data.moduleId },
|
||||||
|
});
|
||||||
|
if (result.count === 0) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Endpoint not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: { moved: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
76
packages/server/src/routes/import.ts
Normal file
76
packages/server/src/routes/import.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Router, type Router as RouterType } from 'express';
|
||||||
|
import { prisma } from '@agent-fox/shared';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { parseOpenApiDocument } from '../services/openapi-parser.js';
|
||||||
|
|
||||||
|
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' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: { id: req.params.id, userId: req.user!.userId },
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = specUrl || spec;
|
||||||
|
const parsed = await parseOpenApiDocument(input);
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.endpoint.deleteMany({ where: { projectId: project.id } });
|
||||||
|
await tx.module.deleteMany({ where: { projectId: project.id } });
|
||||||
|
|
||||||
|
await tx.project.update({
|
||||||
|
where: { id: project.id },
|
||||||
|
data: {
|
||||||
|
name: parsed.name, description: parsed.description, baseUrl: parsed.baseUrl,
|
||||||
|
openApiSpec: parsed.spec as any, openApiVersion: parsed.openApiVersion,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const moduleIdMap = new Map<string, string>();
|
||||||
|
for (let i = 0; i < parsed.modules.length; i++) {
|
||||||
|
const mod = parsed.modules[i];
|
||||||
|
const created = await tx.module.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id, name: mod.name, description: mod.description,
|
||||||
|
sortOrder: i, source: mod.source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
moduleIdMap.set(mod.name, created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ep of parsed.endpoints) {
|
||||||
|
const moduleId = moduleIdMap.get(ep.moduleName);
|
||||||
|
if (!moduleId) continue;
|
||||||
|
await tx.endpoint.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id, moduleId, method: ep.method, path: ep.path,
|
||||||
|
summary: ep.summary, description: ep.description, operationId: ep.operationId,
|
||||||
|
parameters: ep.parameters as any, requestBody: ep.requestBody as any,
|
||||||
|
responses: ep.responses as any, tags: ep.tags, deprecated: ep.deprecated,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { stats: { modules: parsed.modules.length, endpoints: parsed.endpoints.length } },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to parse OpenAPI document';
|
||||||
|
res.status(400).json({ success: false, error: { code: 'PARSE_ERROR', message } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
102
packages/server/src/routes/modules.ts
Normal file
102
packages/server/src/routes/modules.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Router, type Router as RouterType } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '@agent-fox/shared';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router: RouterType = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
async function verifyProjectOwnership(projectId: string, userId: string) {
|
||||||
|
return prisma.project.findFirst({ where: { id: projectId, userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/:id/modules', async (req, res) => {
|
||||||
|
const project = await verifyProjectOwnership(req.params.id, req.user!.userId);
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const modules = await prisma.module.findMany({
|
||||||
|
where: { projectId: req.params.id },
|
||||||
|
include: { _count: { select: { endpoints: true } } },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
res.json({ success: true, data: modules });
|
||||||
|
});
|
||||||
|
|
||||||
|
const createModuleSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/modules', async (req, res) => {
|
||||||
|
const project = await verifyProjectOwnership(req.params.id, req.user!.userId);
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = createModuleSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maxOrder = await prisma.module.aggregate({
|
||||||
|
where: { projectId: req.params.id },
|
||||||
|
_max: { sortOrder: true },
|
||||||
|
});
|
||||||
|
const mod = await prisma.module.create({
|
||||||
|
data: {
|
||||||
|
projectId: req.params.id, name: parsed.data.name,
|
||||||
|
description: parsed.data.description, sortOrder: (maxOrder._max.sortOrder || 0) + 1,
|
||||||
|
source: 'manual',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.status(201).json({ success: true, data: mod });
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateModuleSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
sortOrder: z.number().int().min(0).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id/modules/:mid', async (req, res) => {
|
||||||
|
const project = await verifyProjectOwnership(req.params.id, req.user!.userId);
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = updateModuleSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mod = await prisma.module.updateMany({
|
||||||
|
where: { id: req.params.mid, projectId: req.params.id },
|
||||||
|
data: parsed.data,
|
||||||
|
});
|
||||||
|
if (mod.count === 0) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Module not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = await prisma.module.findUnique({ where: { id: req.params.mid } });
|
||||||
|
res.json({ success: true, data: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id/modules/:mid', async (req, res) => {
|
||||||
|
const project = await verifyProjectOwnership(req.params.id, req.user!.userId);
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await prisma.module.deleteMany({
|
||||||
|
where: { id: req.params.mid, projectId: req.params.id },
|
||||||
|
});
|
||||||
|
if (result.count === 0) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Module not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: { deleted: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
152
packages/server/src/routes/projects.ts
Normal file
152
packages/server/src/routes/projects.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { Router, type Router as RouterType } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '@agent-fox/shared';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { generateApiKey } from '../lib/api-key.js';
|
||||||
|
import { parseOpenApiDocument } from '../services/openapi-parser.js';
|
||||||
|
|
||||||
|
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)' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = specUrl || spec;
|
||||||
|
const parsed = await parseOpenApiDocument(input);
|
||||||
|
const { raw: apiKey, hash: apiKeyHash } = generateApiKey();
|
||||||
|
|
||||||
|
const project = await prisma.$transaction(async (tx) => {
|
||||||
|
const proj = await tx.project.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user!.userId,
|
||||||
|
name: parsed.name,
|
||||||
|
description: parsed.description,
|
||||||
|
baseUrl: parsed.baseUrl,
|
||||||
|
openApiSpec: parsed.spec as any,
|
||||||
|
openApiVersion: parsed.openApiVersion,
|
||||||
|
apiKeyHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const moduleIdMap = new Map<string, string>();
|
||||||
|
for (let i = 0; i < parsed.modules.length; i++) {
|
||||||
|
const mod = parsed.modules[i];
|
||||||
|
const created = await tx.module.create({
|
||||||
|
data: {
|
||||||
|
projectId: proj.id, name: mod.name, description: mod.description,
|
||||||
|
sortOrder: i, source: mod.source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
moduleIdMap.set(mod.name, created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ep of parsed.endpoints) {
|
||||||
|
const moduleId = moduleIdMap.get(ep.moduleName);
|
||||||
|
if (!moduleId) continue;
|
||||||
|
await tx.endpoint.create({
|
||||||
|
data: {
|
||||||
|
projectId: proj.id, moduleId, method: ep.method, path: ep.path,
|
||||||
|
summary: ep.summary, description: ep.description, operationId: ep.operationId,
|
||||||
|
parameters: ep.parameters as any, requestBody: ep.requestBody as any,
|
||||||
|
responses: ep.responses as any, tags: ep.tags, deprecated: ep.deprecated,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return proj;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
project: { id: project.id, name: project.name },
|
||||||
|
apiKey,
|
||||||
|
stats: { modules: parsed.modules.length, endpoints: parsed.endpoints.length },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to parse OpenAPI document';
|
||||||
|
res.status(400).json({ success: false, error: { code: 'PARSE_ERROR', message } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { userId: req.user!.userId },
|
||||||
|
include: { _count: { select: { endpoints: true, modules: true } } },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
res.json({ success: true, data: projects });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: { id: req.params.id, userId: req.user!.userId },
|
||||||
|
include: {
|
||||||
|
modules: {
|
||||||
|
include: { _count: { select: { endpoints: true } } },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
},
|
||||||
|
_count: { select: { endpoints: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: project });
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200).optional(),
|
||||||
|
description: z.string().max(1000).optional(),
|
||||||
|
baseUrl: z.string().url().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
const parsed = updateSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const project = await prisma.project.updateMany({
|
||||||
|
where: { id: req.params.id, userId: req.user!.userId },
|
||||||
|
data: parsed.data,
|
||||||
|
});
|
||||||
|
if (project.count === 0) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = await prisma.project.findUnique({ where: { id: req.params.id } });
|
||||||
|
res.json({ success: true, data: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
const result = await prisma.project.deleteMany({
|
||||||
|
where: { id: req.params.id, userId: req.user!.userId },
|
||||||
|
});
|
||||||
|
if (result.count === 0) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: { deleted: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/api-key/rotate', async (req, res) => {
|
||||||
|
const { raw, hash } = generateApiKey();
|
||||||
|
const result = await prisma.project.updateMany({
|
||||||
|
where: { id: req.params.id, userId: req.user!.userId },
|
||||||
|
data: { apiKeyHash: hash },
|
||||||
|
});
|
||||||
|
if (result.count === 0) {
|
||||||
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: { apiKey: raw } });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
108
packages/server/src/services/openapi-parser.ts
Normal file
108
packages/server/src/services/openapi-parser.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import SwaggerParser from '@apidevtools/swagger-parser';
|
||||||
|
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
|
||||||
|
|
||||||
|
type OpenApiDoc = OpenAPIV3.Document | OpenAPIV3_1.Document;
|
||||||
|
|
||||||
|
export type ParsedModule = {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
source: 'tag' | 'path_prefix';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedEndpoint = {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
summary: string | null;
|
||||||
|
description: string | null;
|
||||||
|
operationId: string | null;
|
||||||
|
parameters: unknown[];
|
||||||
|
requestBody: unknown | null;
|
||||||
|
responses: Record<string, unknown>;
|
||||||
|
tags: string[];
|
||||||
|
deprecated: boolean;
|
||||||
|
moduleName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParseResult = {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
version: string;
|
||||||
|
openApiVersion: string;
|
||||||
|
baseUrl: string | null;
|
||||||
|
spec: unknown;
|
||||||
|
modules: ParsedModule[];
|
||||||
|
endpoints: ParsedEndpoint[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function parseOpenApiDocument(input: string | object): Promise<ParseResult> {
|
||||||
|
const rawApi = await SwaggerParser.validate(input as any);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoints: ParsedEndpoint[] = [];
|
||||||
|
const usedTags = new Set<string>();
|
||||||
|
|
||||||
|
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 OpenAPIV3.OperationObject | undefined;
|
||||||
|
if (!operation) continue;
|
||||||
|
|
||||||
|
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 moduleName = endpointTags[0] || prefix;
|
||||||
|
|
||||||
|
endpoints.push({
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
path: pathStr,
|
||||||
|
summary: operation.summary || null,
|
||||||
|
description: operation.description || null,
|
||||||
|
operationId: operation.operationId || null,
|
||||||
|
parameters: (operation.parameters || []) as unknown[],
|
||||||
|
requestBody: operation.requestBody || null,
|
||||||
|
responses: (operation.responses || {}) as Record<string, unknown>,
|
||||||
|
tags: endpointTags,
|
||||||
|
deprecated: operation.deprecated || false,
|
||||||
|
moduleName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules: ParsedModule[] = [];
|
||||||
|
const moduleNames = new Set<string>();
|
||||||
|
|
||||||
|
for (const [tagName, tagDesc] of tagMap) {
|
||||||
|
if (usedTags.has(tagName)) {
|
||||||
|
modules.push({ name: tagName, description: tagDesc, source: 'tag' });
|
||||||
|
moduleNames.add(tagName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
if (endpoint.tags.length === 0 && !moduleNames.has(endpoint.moduleName)) {
|
||||||
|
modules.push({ name: endpoint.moduleName, description: null, source: 'path_prefix' });
|
||||||
|
moduleNames.add(endpoint.moduleName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, description, version, openApiVersion, baseUrl, spec: api, modules, endpoints };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user