mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(api): section-prefix factory + batch routes via apiToolPath (+section validation)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Batch processing route.
|
||||
*
|
||||
* POST /api/v1/tools/:toolId/batch
|
||||
* POST /api/v1/tools/:section/:toolId/batch
|
||||
*
|
||||
* Accepts multipart with multiple files + settings JSON.
|
||||
* Each file is enqueued as a batch-child BullMQ job; a batch-finalize
|
||||
@@ -12,7 +12,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getBundleForTool, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
|
||||
import { getBundleForTool, TOOL_BUNDLE_MAP, TOOLS, toolSection } from "@snapotter/shared";
|
||||
import archiver from "archiver";
|
||||
import type { FlowJob } from "bullmq";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -56,10 +56,17 @@ function injectTraceContextIntoFlow(node: FlowJob): void {
|
||||
|
||||
export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post(
|
||||
"/api/v1/tools/:toolId/batch",
|
||||
"/api/v1/tools/:section/:toolId/batch",
|
||||
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (request: FastifyRequest<{ Params: { toolId: string } }>, reply: FastifyReply) => {
|
||||
const { toolId } = request.params;
|
||||
async (
|
||||
request: FastifyRequest<{ Params: { section: string; toolId: string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { section, toolId } = request.params;
|
||||
const tool = TOOLS.find((t) => t.id === toolId);
|
||||
if (!tool || toolSection(tool) !== section) {
|
||||
return reply.status(404).send({ error: "Not found", code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
// Batch processing (especially with AI) can take tens of minutes.
|
||||
// Disable the Node.js HTTP socket timeout so the connection is not
|
||||
@@ -182,7 +189,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
// Resolve the tool's modality so non-image files (audio/video/document)
|
||||
// validate through their own handler instead of the image validator.
|
||||
const modality = TOOLS.find((t) => t.id === toolId)?.modality ?? "image";
|
||||
const modality = tool.modality;
|
||||
const batchScratch = join(tmpdir(), "snapotter-scratch", `batch-${parentId}`);
|
||||
await mkdir(batchScratch, { recursive: true });
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@ import { randomUUID } from "node:crypto";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { extname, join } from "node:path";
|
||||
import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
|
||||
import {
|
||||
ANALYTICS_EVENTS,
|
||||
apiToolPath,
|
||||
getBundleForTool,
|
||||
TOOL_BUNDLE_MAP,
|
||||
TOOLS,
|
||||
} from "@snapotter/shared";
|
||||
import { and, inArray, sql } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { z } from "zod";
|
||||
@@ -186,7 +192,7 @@ export function registerToolProcessFn(config: AnyToolRouteConfig): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that registers a POST /api/v1/tools/:toolId route.
|
||||
* Factory that registers a POST /api/v1/tools/:section/:toolId route.
|
||||
*
|
||||
* The route accepts multipart with:
|
||||
* - A file part (the image to process)
|
||||
@@ -211,7 +217,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
toolRegistry.set(config.toolId, resolved);
|
||||
|
||||
app.post(
|
||||
`/api/v1/tools/${config.toolId}`,
|
||||
apiToolPath(config.toolId),
|
||||
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
// Check per-tool access before processing uploads
|
||||
|
||||
@@ -137,6 +137,7 @@ vi.mock("sharp", () => ({
|
||||
|
||||
// ── Imports ─────────────────────────────────────────────────────────────
|
||||
|
||||
import { apiToolPath } from "@snapotter/shared";
|
||||
import { enqueueToolJob, waitForJob } from "../../../apps/api/src/jobs/enqueue.js";
|
||||
import { isToolInstalled } from "../../../apps/api/src/lib/feature-status.js";
|
||||
import { validateImageBuffer } from "../../../apps/api/src/lib/file-validation.js";
|
||||
@@ -149,10 +150,6 @@ import {
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function uniqueId() {
|
||||
return `factory-test-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/** Build a mock Zod-like schema that accepts everything. */
|
||||
function acceptAllSchema() {
|
||||
return {
|
||||
@@ -276,12 +273,12 @@ describe("createToolRoute", () => {
|
||||
});
|
||||
|
||||
describe("route registration", () => {
|
||||
it("registers a POST route at /api/v1/tools/:toolId", () => {
|
||||
it("registers a POST route at /api/v1/tools/:section/:toolId", () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
expect(app.post).toHaveBeenCalledWith(
|
||||
`/api/v1/tools/${id}`,
|
||||
apiToolPath(id),
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -289,7 +286,7 @@ describe("createToolRoute", () => {
|
||||
|
||||
it("adds the tool config to the registry", () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
expect(getToolConfig(id)).toBeDefined();
|
||||
expect(getRegisteredToolIds()).toContain(id);
|
||||
@@ -299,9 +296,9 @@ describe("createToolRoute", () => {
|
||||
describe("request handling", () => {
|
||||
it("returns 400 when no file is provided", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({});
|
||||
|
||||
@@ -315,9 +312,9 @@ describe("createToolRoute", () => {
|
||||
|
||||
it("returns 400 when multiple files are provided", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
@@ -340,9 +337,9 @@ describe("createToolRoute", () => {
|
||||
reason: "Corrupt image data",
|
||||
} as never);
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({ fileBuffer: Buffer.from("bad") });
|
||||
|
||||
@@ -358,9 +355,9 @@ describe("createToolRoute", () => {
|
||||
|
||||
it("returns 400 when settings JSON is invalid", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
@@ -377,9 +374,9 @@ describe("createToolRoute", () => {
|
||||
|
||||
it("returns 400 when settings fail Zod validation", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id, strictQualitySchema()));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
@@ -397,9 +394,9 @@ describe("createToolRoute", () => {
|
||||
it("returns 501 when AI feature bundle is not installed", async () => {
|
||||
vi.mocked(isToolInstalled).mockReturnValueOnce(false);
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
@@ -416,9 +413,9 @@ describe("createToolRoute", () => {
|
||||
|
||||
it("returns 200 success envelope when waitForJob resolves", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
@@ -447,9 +444,9 @@ describe("createToolRoute", () => {
|
||||
resultPayload: { found: 3 },
|
||||
});
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("pdf-data"),
|
||||
@@ -470,9 +467,9 @@ describe("createToolRoute", () => {
|
||||
it("omits resultPayload fields when processV2 returns no resultPayload", async () => {
|
||||
// Default mock already returns no resultPayload
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
@@ -492,9 +489,9 @@ describe("createToolRoute", () => {
|
||||
it("returns 202 when waitForJob returns null (sync window expired)", async () => {
|
||||
vi.mocked(waitForJob).mockResolvedValueOnce(null);
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
@@ -510,9 +507,9 @@ describe("createToolRoute", () => {
|
||||
it("returns 422 when waitForJob rejects", async () => {
|
||||
vi.mocked(waitForJob).mockRejectedValueOnce(new Error("Sharp exploded"));
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
@@ -532,9 +529,9 @@ describe("createToolRoute", () => {
|
||||
|
||||
it("uses empty settings when none are provided", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
@@ -551,9 +548,9 @@ describe("createToolRoute", () => {
|
||||
describe("multipart parsing error", () => {
|
||||
it("returns 400 when parts iterator throws", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = {
|
||||
parts: () => ({
|
||||
@@ -580,9 +577,9 @@ describe("createToolRoute", () => {
|
||||
describe("multipart field recovery", () => {
|
||||
it("recovers settings from part.fields when the iterator drops trailing fields", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
|
||||
// Simulate the @fastify/multipart race: the iterator yields only the
|
||||
@@ -617,9 +614,9 @@ describe("createToolRoute", () => {
|
||||
|
||||
it("does not overwrite settings already collected from the iterator", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
|
||||
// Both the iterator and part.fields carry settings; the iterator value wins
|
||||
@@ -658,9 +655,9 @@ describe("createToolRoute", () => {
|
||||
|
||||
it("recovers fileId and clientJobId from part.fields", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
|
||||
const req = {
|
||||
@@ -692,9 +689,9 @@ describe("createToolRoute", () => {
|
||||
|
||||
it("handles array-form fields from part.fields", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
|
||||
const req = {
|
||||
|
||||
Reference in New Issue
Block a user