feat(api): add file upload api, get a upload url, then upload file, max 50mb for now

This commit is contained in:
BennyKok 2024-01-16 00:49:50 +08:00
parent 478859d9b5
commit 1ced8095dc
6 changed files with 358 additions and 216 deletions

View File

@ -1,43 +1,21 @@
import { createRun } from "../../../../server/createRun"; import { app } from "../../../../routes/app";
import { db } from "@/db/db"; import { registerCreateRunRoute } from "@/routes/registerCreateRunRoute";
import { deploymentsTable, workflowRunsTable } from "@/db/schema"; import { registerGetOutputRoute } from "@/routes/registerGetOutputRoute";
import { createSelectSchema } from "@/lib/drizzle-zod-hono"; import { registerUploadRoute } from "@/routes/registerUploadRoute";
import { isKeyRevoked } from "@/server/curdApiKeys"; import { isKeyRevoked } from "@/server/curdApiKeys";
import { getRunsData } from "@/server/getRunsData";
import { parseJWT } from "@/server/parseJWT"; import { parseJWT } from "@/server/parseJWT";
import type { ResponseConfig } from "@asteasolutions/zod-to-openapi"; import type { Context, Next } from "hono";
import { z, createRoute } from "@hono/zod-openapi";
import { OpenAPIHono } from "@hono/zod-openapi";
import { eq } from "drizzle-orm";
import { handle } from "hono/vercel"; import { handle } from "hono/vercel";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const app = new OpenAPIHono().basePath("/api");
declare module "hono" { declare module "hono" {
interface ContextVariableMap { interface ContextVariableMap {
apiKeyTokenData: ReturnType<typeof parseJWT>; apiKeyTokenData: ReturnType<typeof parseJWT>;
} }
} }
const authError = { async function checkAuth(c: Context, next: Next) {
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) => {
const token = c.req.raw.headers.get("Authorization")?.split(" ")?.[1]; // Assuming token is sent as "Bearer your_token" const token = c.req.raw.headers.get("Authorization")?.split(" ")?.[1]; // Assuming token is sent as "Bearer your_token"
const userData = token ? parseJWT(token) : undefined; const userData = token ? parseJWT(token) : undefined;
if (!userData || token === undefined) { if (!userData || token === undefined) {
@ -50,198 +28,19 @@ app.use("/run", async (c, next) => {
c.set("apiKeyTokenData", userData); c.set("apiKeyTokenData", userData);
await next(); await 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.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,
}
);
} }
app.use("/run", async (c, next) => {
return checkAuth(c, next);
}); });
const createRunRoute = createRoute({ app.use("/upload", async (c, next) => {
method: "post", return checkAuth(c, next);
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) => { registerCreateRunRoute(app);
const data = c.req.valid("json"); registerGetOutputRoute(app);
const origin = new URL(c.req.url).origin; registerUploadRoute(app);
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,
}
);
}
});
// The OpenAPI documentation will be available at /doc // The OpenAPI documentation will be available at /doc
app.doc("/doc", { app.doc("/doc", {

4
web/src/routes/app.ts Normal file
View File

@ -0,0 +1,4 @@
import { OpenAPIHono } from "@hono/zod-openapi";
export const app = new OpenAPIHono().basePath("/api");
export type App = typeof app;

View File

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

View File

@ -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,
}
);
}
});
};

View File

@ -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,
}
);
}
});
};

View File

@ -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,
}
);
}
});
};