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:
2026-04-02 11:48:06 +08:00
parent 896115a438
commit a191a4db00
8 changed files with 548 additions and 0 deletions

View File

@@ -10,10 +10,12 @@
},
"dependencies": {
"@agent-fox/shared": "workspace:*",
"@apidevtools/swagger-parser": "^12.1.0",
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"express": "^5.0.0",
"jsonwebtoken": "^9.0.3",
"openapi-types": "^12.1.3",
"zod": "^3.24.0"
},
"devDependencies": {

View File

@@ -1,6 +1,10 @@
import express from 'express';
import cors from 'cors';
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();
app.use(cors());
@@ -11,6 +15,10 @@ app.get('/api/health', (_req, res) => {
});
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;
app.listen(port, () => {

View 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);
}

View 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;

View 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;

View 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;

View 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;

View 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 };
}