feat(api): add pipeline execution, save, and list endpoints

Add pipelines table to SQLite schema with Drizzle migration.
Implement POST /api/v1/pipeline/execute (sequential multi-tool processing),
POST /api/v1/pipeline/save, GET /api/v1/pipeline/list,
DELETE /api/v1/pipeline/:id. Pipeline execution validates all tool IDs
and settings before processing, chains output of each step as input
to the next.
This commit is contained in:
Siddharth Kumar Sah
2026-03-22 04:41:51 +08:00
parent 8e8ba34dd8
commit 263447a81e
6 changed files with 702 additions and 0 deletions
@@ -0,0 +1,7 @@
CREATE TABLE `pipelines` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`description` text,
`steps` text NOT NULL,
`created_at` integer NOT NULL
);
+364
View File
@@ -0,0 +1,364 @@
{
"version": "6",
"dialect": "sqlite",
"id": "91a14a95-bbcb-46ef-abe3-6d2f6fbc8458",
"prevId": "c7909605-aabf-4832-8ef8-9390c0f7c99a",
"tables": {
"api_keys": {
"name": "api_keys",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'Default API Key'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_used_at": {
"name": "last_used_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"api_keys_user_id_users_id_fk": {
"name": "api_keys_user_id_users_id_fk",
"tableFrom": "api_keys",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"jobs": {
"name": "jobs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'queued'"
},
"progress": {
"name": "progress",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"input_files": {
"name": "input_files",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"output_path": {
"name": "output_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"pipelines": {
"name": "pipelines",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"steps": {
"name": "steps",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"must_change_password": {
"name": "must_change_password",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1774119039901, "when": 1774119039901,
"tag": "0000_clammy_madelyne_pryor", "tag": "0000_clammy_madelyne_pryor",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1774125684700,
"tag": "0001_amusing_omega_red",
"breakpoints": true
} }
] ]
} }
+8
View File
@@ -44,3 +44,11 @@ export const apiKeys = sqliteTable("api_keys", {
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()), createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
lastUsedAt: integer("last_used_at", { mode: "timestamp" }), lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
}); });
export const pipelines = sqliteTable("pipelines", {
id: text("id").primaryKey(),
name: text("name").notNull(),
description: text("description"),
steps: text("steps").notNull(), // JSON array of { toolId, settings }
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
});
+4
View File
@@ -13,6 +13,7 @@ import { startCleanupCron } from "./lib/cleanup.js";
import { fileRoutes } from "./routes/files.js"; import { fileRoutes } from "./routes/files.js";
import { registerToolRoutes } from "./routes/tools/index.js"; import { registerToolRoutes } from "./routes/tools/index.js";
import { registerBatchRoutes } from "./routes/batch.js"; import { registerBatchRoutes } from "./routes/batch.js";
import { registerPipelineRoutes } from "./routes/pipeline.js";
import { registerProgressRoutes } from "./routes/progress.js"; import { registerProgressRoutes } from "./routes/progress.js";
// Run before anything else // Run before anything else
@@ -68,6 +69,9 @@ await registerToolRoutes(app);
// Batch processing routes (must be after tool routes so the registry is populated) // Batch processing routes (must be after tool routes so the registry is populated)
await registerBatchRoutes(app); await registerBatchRoutes(app);
// Pipeline routes (must be after tool routes so the registry is populated)
await registerPipelineRoutes(app);
// Progress SSE routes // Progress SSE routes
await registerProgressRoutes(app); await registerProgressRoutes(app);
+312
View File
@@ -0,0 +1,312 @@
/**
* Pipeline execution, save, list, and delete routes.
*
* POST /api/v1/pipeline/execute — Execute a pipeline (array of tool steps)
* POST /api/v1/pipeline/save — Save a pipeline definition
* GET /api/v1/pipeline/list — List saved pipelines
* DELETE /api/v1/pipeline/:id — Delete a saved pipeline
*/
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { getToolConfig } from "./tool-factory.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { createWorkspace } from "../lib/workspace.js";
import { db, schema } from "../db/index.js";
/** Schema for a single pipeline step. */
const pipelineStepSchema = z.object({
toolId: z.string(),
settings: z.record(z.unknown()).default({}),
});
/** Schema for a full pipeline definition. */
const pipelineDefinitionSchema = z.object({
steps: z.array(pipelineStepSchema).min(1, "Pipeline must have at least one step"),
});
/** Schema for saving a pipeline. */
const savePipelineSchema = z.object({
name: z.string().min(1, "Pipeline name is required").max(100),
description: z.string().max(500).optional(),
steps: z.array(pipelineStepSchema).min(1, "Pipeline must have at least one step"),
});
/**
* Sanitize a filename to prevent path traversal attacks.
*/
function sanitizeFilename(raw: string): string {
let name = basename(raw);
name = name.replace(/\.\./g, "");
name = name.replace(/\0/g, "");
if (!name || name === "." || name === "..") {
name = "image";
}
return name;
}
export async function registerPipelineRoutes(app: FastifyInstance): Promise<void> {
/**
* POST /api/v1/pipeline/execute
*
* Accepts multipart with:
* - A file part (the image to process)
* - A "pipeline" field containing JSON: { steps: [{ toolId, settings }, ...] }
*
* Processes the image through each step sequentially.
* The output of step N becomes the input of step N+1.
* Returns the final processed image for download.
*/
app.post(
"/api/v1/pipeline/execute",
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let pipelineRaw: string | null = null;
// Parse multipart
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "pipeline") {
pipelineRaw = part.value as string;
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
// Validate the initial image
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid image: ${validation.reason}`,
});
}
// Parse and validate the pipeline definition
if (!pipelineRaw) {
return reply.status(400).send({ error: "No pipeline definition provided" });
}
let pipeline: z.infer<typeof pipelineDefinitionSchema>;
try {
const parsed = JSON.parse(pipelineRaw);
const result = pipelineDefinitionSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({
error: "Invalid pipeline definition",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
});
}
pipeline = result.data;
} catch {
return reply.status(400).send({ error: "Pipeline must be valid JSON" });
}
// Validate all tool IDs exist before starting
for (let i = 0; i < pipeline.steps.length; i++) {
const step = pipeline.steps[i];
const toolConfig = getToolConfig(step.toolId);
if (!toolConfig) {
return reply.status(400).send({
error: `Step ${i + 1}: Tool "${step.toolId}" not found`,
});
}
// Validate the settings for this tool
const settingsResult = toolConfig.settingsSchema.safeParse(step.settings);
if (!settingsResult.success) {
return reply.status(400).send({
error: `Step ${i + 1} (${step.toolId}): Invalid settings`,
details: settingsResult.error.issues.map((iss: { path: (string | number)[]; message: string }) => ({
path: iss.path.join("."),
message: iss.message,
})),
});
}
}
// Execute the pipeline: pass the buffer through each step sequentially
let currentBuffer = fileBuffer;
let currentFilename = filename;
const stepResults: Array<{ step: number; toolId: string; size: number }> = [];
try {
for (let i = 0; i < pipeline.steps.length; i++) {
const step = pipeline.steps[i];
const toolConfig = getToolConfig(step.toolId)!;
// Parse settings through the schema to apply defaults
const settings = toolConfig.settingsSchema.parse(step.settings);
const result = await toolConfig.process(currentBuffer, settings, currentFilename);
stepResults.push({
step: i + 1,
toolId: step.toolId,
size: result.buffer.length,
});
currentBuffer = result.buffer;
currentFilename = result.filename;
}
} catch (err) {
const message = err instanceof Error ? err.message : "Pipeline processing failed";
return reply.status(422).send({
error: "Pipeline processing failed",
details: message,
completedSteps: stepResults,
});
}
// Save the final output to workspace
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", currentFilename);
await writeFile(outputPath, currentBuffer);
// Also save the original input for reference
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(currentFilename)}`,
originalSize: fileBuffer.length,
processedSize: currentBuffer.length,
stepsCompleted: stepResults.length,
steps: stepResults,
});
},
);
/**
* POST /api/v1/pipeline/save
*
* Save a named pipeline definition for later reuse.
*/
app.post(
"/api/v1/pipeline/save",
async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as unknown;
const result = savePipelineSchema.safeParse(body);
if (!result.success) {
return reply.status(400).send({
error: "Invalid pipeline definition",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
});
}
const { name, description, steps } = result.data;
// Validate all tool IDs exist
for (let i = 0; i < steps.length; i++) {
const toolConfig = getToolConfig(steps[i].toolId);
if (!toolConfig) {
return reply.status(400).send({
error: `Step ${i + 1}: Tool "${steps[i].toolId}" not found`,
});
}
}
const id = randomUUID();
db.insert(schema.pipelines)
.values({
id,
name,
description: description ?? null,
steps: JSON.stringify(steps),
})
.run();
return reply.status(201).send({
id,
name,
description: description ?? null,
steps,
createdAt: new Date().toISOString(),
});
},
);
/**
* GET /api/v1/pipeline/list
*
* List all saved pipelines.
*/
app.get(
"/api/v1/pipeline/list",
async (_request: FastifyRequest, reply: FastifyReply) => {
const rows = db.select().from(schema.pipelines).all();
const pipelines = rows.map((row) => ({
id: row.id,
name: row.name,
description: row.description,
steps: JSON.parse(row.steps),
createdAt: row.createdAt.toISOString(),
}));
return reply.send({ pipelines });
},
);
/**
* DELETE /api/v1/pipeline/:id
*
* Delete a saved pipeline by its ID.
*/
app.delete(
"/api/v1/pipeline/:id",
async (
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) => {
const { id } = request.params;
const existing = db
.select()
.from(schema.pipelines)
.where(eq(schema.pipelines.id, id))
.get();
if (!existing) {
return reply.status(404).send({ error: "Pipeline not found" });
}
db.delete(schema.pipelines)
.where(eq(schema.pipelines.id, id))
.run();
return reply.send({ ok: true });
},
);
app.log.info("Pipeline routes registered");
}