diff --git a/web/src/app/(app)/api/[[...routes]]/route.ts b/web/src/app/(app)/api/[[...routes]]/route.ts index 3a84d98..008fa9f 100644 --- a/web/src/app/(app)/api/[[...routes]]/route.ts +++ b/web/src/app/(app)/api/[[...routes]]/route.ts @@ -1,43 +1,21 @@ -import { createRun } from "../../../../server/createRun"; -import { db } from "@/db/db"; -import { deploymentsTable, workflowRunsTable } from "@/db/schema"; -import { createSelectSchema } from "@/lib/drizzle-zod-hono"; +import { app } from "../../../../routes/app"; +import { registerCreateRunRoute } from "@/routes/registerCreateRunRoute"; +import { registerGetOutputRoute } from "@/routes/registerGetOutputRoute"; +import { registerUploadRoute } from "@/routes/registerUploadRoute"; import { isKeyRevoked } from "@/server/curdApiKeys"; -import { getRunsData } from "@/server/getRunsData"; import { parseJWT } from "@/server/parseJWT"; -import type { ResponseConfig } from "@asteasolutions/zod-to-openapi"; -import { z, createRoute } from "@hono/zod-openapi"; -import { OpenAPIHono } from "@hono/zod-openapi"; -import { eq } from "drizzle-orm"; +import type { Context, Next } from "hono"; import { handle } from "hono/vercel"; export const dynamic = "force-dynamic"; -const app = new OpenAPIHono().basePath("/api"); - declare module "hono" { interface ContextVariableMap { apiKeyTokenData: ReturnType; } } -const authError = { - 401: { - content: { - "text/plain": { - schema: z.string().openapi({ - type: "string", - example: "Invalid or expired token", - }), - }, - }, - description: "Invalid or expired token", - }, -} satisfies { - [statusCode: string]: ResponseConfig; -}; - -app.use("/run", async (c, next) => { +async function checkAuth(c: Context, next: Next) { const token = c.req.raw.headers.get("Authorization")?.split(" ")?.[1]; // Assuming token is sent as "Bearer your_token" const userData = token ? parseJWT(token) : undefined; if (!userData || token === undefined) { @@ -50,198 +28,19 @@ app.use("/run", async (c, next) => { c.set("apiKeyTokenData", userData); await next(); +} + +app.use("/run", async (c, next) => { + return checkAuth(c, next); }); -// console.log(RunOutputZod.shape); - -const getOutputRoute = createRoute({ - method: "get", - path: "/run", - tags: ["workflows"], - summary: "Get workflow run output", - description: - "Call this to get a run's output, usually in conjunction with polling method", - request: { - query: z.object({ - run_id: z.string(), - }), - }, - responses: { - 200: { - content: { - "application/json": { - // https://github.com/asteasolutions/zod-to-openapi/issues/194 - schema: createSelectSchema(workflowRunsTable, { - workflow_inputs: (schema) => - schema.workflow_inputs.openapi({ - type: "object", - example: { - input_text: "some external text input", - input_image: "https://somestatic.png", - }, - }), - }), - }, - }, - description: "Retrieve the output", - }, - 400: { - content: { - "application/json": { - schema: z.object({ - code: z.number().openapi({ - type: "string", - example: 400, - }), - message: z.string().openapi({ - type: "string", - example: "Workflow not found", - }), - }), - }, - }, - description: "Workflow not found", - }, - 500: { - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - description: "Error getting output", - }, - ...authError, - }, +app.use("/upload", async (c, next) => { + return checkAuth(c, next); }); -app.openapi(getOutputRoute, async (c) => { - const data = c.req.valid("query"); - const apiKeyTokenData = c.get("apiKeyTokenData")!; - - try { - const run = await getRunsData(data.run_id, apiKeyTokenData); - - if (!run) - return c.json( - { - code: 400, - message: "Workflow not found", - }, - 400 - ); - - return c.json(run, 200); - } catch (error: any) { - return c.json( - { - error: error.message, - }, - { - status: 500, - } - ); - } -}); - -const createRunRoute = createRoute({ - method: "post", - path: "/run", - tags: ["workflows"], - summary: "Run a workflow via deployment_id", - request: { - body: { - content: { - "application/json": { - schema: z.object({ - deployment_id: z.string(), - inputs: z.record(z.string()).optional(), - }), - }, - }, - }, - // headers: z.object({ - // "Authorization": z. - // }) - }, - responses: { - 200: { - content: { - "application/json": { - schema: z.object({ - run_id: z.string(), - }), - }, - }, - description: "Workflow queued", - }, - 500: { - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - description: "Error creating run", - }, - ...authError, - }, -}); - -app.openapi(createRunRoute, async (c) => { - const data = c.req.valid("json"); - const origin = new URL(c.req.url).origin; - const apiKeyTokenData = c.get("apiKeyTokenData")!; - - const { deployment_id, inputs } = data; - - try { - const deploymentData = await db.query.deploymentsTable.findFirst({ - where: eq(deploymentsTable.id, deployment_id), - with: { - machine: true, - version: { - with: { - workflow: { - columns: { - org_id: true, - user_id: true, - }, - }, - }, - }, - }, - }); - - if (!deploymentData) throw new Error("Deployment not found"); - - const run_id = await createRun({ - origin, - workflow_version_id: deploymentData.version, - machine_id: deploymentData.machine, - inputs, - isManualRun: false, - apiUser: apiKeyTokenData, - }); - - if ("error" in run_id) throw new Error(run_id.error); - - return c.json({ - run_id: "workflow_run_id" in run_id ? run_id.workflow_run_id : "", - }); - } catch (error: any) { - return c.json( - { - error: error.message, - }, - { - status: 500, - } - ); - } -}); +registerCreateRunRoute(app); +registerGetOutputRoute(app); +registerUploadRoute(app); // The OpenAPI documentation will be available at /doc app.doc("/doc", { diff --git a/web/src/routes/app.ts b/web/src/routes/app.ts new file mode 100644 index 0000000..5974062 --- /dev/null +++ b/web/src/routes/app.ts @@ -0,0 +1,4 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; + +export const app = new OpenAPIHono().basePath("/api"); +export type App = typeof app; diff --git a/web/src/routes/authError.ts b/web/src/routes/authError.ts new file mode 100644 index 0000000..5118314 --- /dev/null +++ b/web/src/routes/authError.ts @@ -0,0 +1,18 @@ +import type { ResponseConfig } from "@asteasolutions/zod-to-openapi"; +import { z } from "@hono/zod-openapi"; + +export const authError = { + 401: { + content: { + "text/plain": { + schema: z.string().openapi({ + type: "string", + example: "Invalid or expired token", + }), + }, + }, + description: "Invalid or expired token", + }, +} satisfies { + [statusCode: string]: ResponseConfig; +}; diff --git a/web/src/routes/registerCreateRunRoute.ts b/web/src/routes/registerCreateRunRoute.ts new file mode 100644 index 0000000..6c7fc0c --- /dev/null +++ b/web/src/routes/registerCreateRunRoute.ts @@ -0,0 +1,106 @@ +import { createRun } from "../server/createRun"; +import { db } from "@/db/db"; +import { deploymentsTable } from "@/db/schema"; +import type { App } from "@/routes/app"; +import { authError } from "@/routes/authError"; +import { z, createRoute } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; + +const createRunRoute = createRoute({ + method: "post", + path: "/run", + tags: ["workflows"], + summary: "Run a workflow via deployment_id", + request: { + body: { + content: { + "application/json": { + schema: z.object({ + deployment_id: z.string(), + inputs: z.record(z.string()).optional(), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + run_id: z.string(), + }), + }, + }, + description: "Workflow queued", + }, + 500: { + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + description: "Error creating run", + }, + ...authError, + }, +}); + +export const registerCreateRunRoute = (app: App) => { + app.openapi(createRunRoute, async (c) => { + const data = c.req.valid("json"); + const origin = new URL(c.req.url).origin; + const apiKeyTokenData = c.get("apiKeyTokenData")!; + + const { deployment_id, inputs } = data; + + try { + const deploymentData = await db.query.deploymentsTable.findFirst({ + where: eq(deploymentsTable.id, deployment_id), + with: { + machine: true, + version: { + with: { + workflow: { + columns: { + org_id: true, + user_id: true, + }, + }, + }, + }, + }, + }); + + if (!deploymentData) throw new Error("Deployment not found"); + + const run_id = await createRun({ + origin, + workflow_version_id: deploymentData.version, + machine_id: deploymentData.machine, + inputs, + isManualRun: false, + apiUser: apiKeyTokenData, + }); + + if ("error" in run_id) throw new Error(run_id.error); + + return c.json({ + run_id: "workflow_run_id" in run_id ? run_id.workflow_run_id : "", + }); + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return c.json( + { + error: errorMessage, + }, + { + status: 500, + } + ); + } + }); +}; diff --git a/web/src/routes/registerGetOutputRoute.ts b/web/src/routes/registerGetOutputRoute.ts new file mode 100644 index 0000000..410c6ea --- /dev/null +++ b/web/src/routes/registerGetOutputRoute.ts @@ -0,0 +1,101 @@ +import { workflowRunsTable } from "@/db/schema"; +import { createSelectSchema } from "@/lib/drizzle-zod-hono"; +import type { App } from "@/routes/app"; +import { authError } from "@/routes/authError"; +import { getRunsData } from "@/server/getRunsData"; +import { z, createRoute } from "@hono/zod-openapi"; + +const getOutputRoute = createRoute({ + method: "get", + path: "/run", + tags: ["workflows"], + summary: "Get workflow run output", + description: + "Call this to get a run's output, usually in conjunction with polling method", + request: { + query: z.object({ + run_id: z.string(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + // https://github.com/asteasolutions/zod-to-openapi/issues/194 + schema: createSelectSchema(workflowRunsTable, { + workflow_inputs: (schema) => + schema.workflow_inputs.openapi({ + type: "object", + example: { + input_text: "some external text input", + input_image: "https://somestatic.png", + }, + }), + }), + }, + }, + description: "Retrieve the output", + }, + 400: { + content: { + "application/json": { + schema: z.object({ + code: z.number().openapi({ + type: "string", + example: 400, + }), + message: z.string().openapi({ + type: "string", + example: "Workflow not found", + }), + }), + }, + }, + description: "Workflow not found", + }, + 500: { + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + description: "Error getting output", + }, + ...authError, + }, +}); + +export const registerGetOutputRoute = (app: App) => { + app.openapi(getOutputRoute, async (c) => { + const data = c.req.valid("query"); + const apiKeyTokenData = c.get("apiKeyTokenData")!; + + try { + const run = await getRunsData(data.run_id, apiKeyTokenData); + + if (!run) + return c.json( + { + code: 400, + message: "Workflow not found", + }, + 400 + ); + + return c.json(run, 200); + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return c.json( + { + error: errorMessage, + }, + { + status: 500, + } + ); + } + }); +}; diff --git a/web/src/routes/registerUploadRoute.ts b/web/src/routes/registerUploadRoute.ts new file mode 100644 index 0000000..b58d76b --- /dev/null +++ b/web/src/routes/registerUploadRoute.ts @@ -0,0 +1,114 @@ +import type { App } from "@/routes/app"; +import { authError } from "@/routes/authError"; +import { getFileDownloadUrl } from "@/server/getFileDownloadUrl"; +import { handleResourceUpload } from "@/server/resource"; +import { z, createRoute } from "@hono/zod-openapi"; +import { customAlphabet } from "nanoid"; + +export const nanoid = customAlphabet( + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +); + +const prefixes = { + img: "img", + vid: "vid", +} as const; + +export function newId(prefix: keyof typeof prefixes): string { + return [prefixes[prefix], nanoid(16)].join("_"); +} + +const uploadUrlRoute = createRoute({ + method: "get", + path: "/upload-url", + tags: ["files"], + summary: "Upload any files to the storage", + description: + "Usually when you run something, you want to upload a file, image etc.", + request: { + query: z.object({ + type: z.enum(["image/png", "image/jpg", "image/jpeg"]), + file_size: z + .string() + .refine((value) => !isNaN(Number(value)), { + message: "file_size must be a number", + path: ["file_size"], + }) + .refine((value) => Number(value) > 0, { + message: "file_size cannot be less than or equal to 0", + path: ["file_size"], + }) + .transform((v) => parseFloat(v)), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + upload_url: z.string(), + file_id: z.string(), + download_url: z.string(), + }), + }, + }, + description: "Retrieve the output", + }, + 500: { + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + description: "Error when generating upload url", + }, + ...authError, + }, +}); + +export const registerUploadRoute = (app: App) => { + app.openapi(uploadUrlRoute, async (c) => { + const data = c.req.valid("query"); + + const sizeLimit = 50; + + try { + if (data.file_size > sizeLimit * 1024 * 1024) { + // Check if the file size is greater than 50MB + throw new Error(`File size exceeds ${sizeLimit}MB limit`); + } + + const id = newId("img"); + const filePath = `inputs/${id}.${data.type.split("/")[1]}`; + + const uploadUrl = await handleResourceUpload({ + resourceBucket: process.env.SPACES_BUCKET, + resourceId: filePath, + resourceType: data.type, + isPublic: true, + }); + + return c.json( + { + upload_url: uploadUrl, + file_id: id, + download_url: await getFileDownloadUrl(filePath), + }, + 200 + ); + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return c.json( + { + error: errorMessage, + }, + { + status: 500, + } + ); + } + }); +};