feat: add interactive api docs, rewrite run endpoint with hono and zod validator

This commit is contained in:
BennyKok
2024-01-09 14:00:16 +08:00
parent e921d4c320
commit 7d36f8af88
11 changed files with 756 additions and 190 deletions
@@ -0,0 +1,295 @@
import { createRun } from "../../../../server/createRun";
import { db } from "@/db/db";
import { deploymentsTable, workflowRunsTable } from "@/db/schema";
import { createSelectSchema } from "@/lib/drizzle-zod-hono";
import { isKeyRevoked } from "@/server/curdApiKeys";
import { getRunsData } from "@/server/getRunsOutput";
import { parseJWT } from "@/server/parseJWT";
import { replaceCDNUrl } from "@/server/replaceCDNUrl";
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 { handle } from "hono/vercel";
export const dynamic = "force-dynamic";
export const app = new OpenAPIHono().basePath("/api");
declare module "hono" {
interface ContextVariableMap {
apiKeyTokenData: ReturnType<typeof parseJWT>;
}
}
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) => {
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) {
return c.text("Invalid or expired token", 401);
} else {
const revokedKey = await isKeyRevoked(token);
if (revokedKey) return c.text("Revoked token", 401);
}
c.set("apiKeyTokenData", userData);
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(apiKeyTokenData, data.run_id);
if (!run)
return c.json(
{
code: 400,
message: "Workflow not found",
},
400
);
// Fill in the CDN url
if (run?.status === "success" && run?.outputs?.length > 0) {
for (let i = 0; i < run.outputs.length; i++) {
const output = run.outputs[i];
if (output.data?.images !== undefined) {
for (let j = 0; j < output.data?.images.length; j++) {
const element = output.data?.images[j];
element.url = replaceCDNUrl(
`${process.env.SPACES_ENDPOINT}/${process.env.SPACES_BUCKET}/outputs/runs/${run.id}/${element.filename}`
);
}
} else if (output.data?.files !== undefined) {
for (let j = 0; j < output.data?.files.length; j++) {
const element = output.data?.files[j];
element.url = replaceCDNUrl(
`${process.env.SPACES_ENDPOINT}/${process.env.SPACES_BUCKET}/outputs/runs/${run.id}/${element.filename}`
);
}
}
}
}
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,
}
);
}
});
// The OpenAPI documentation will be available at /doc
app.doc("/doc", {
openapi: "3.0.0",
servers: [{ url: "/api" }],
security: [{ bearerAuth: [] }],
info: {
version: "0.0.1",
title: "Comfy Deploy API",
description:
"Interact with Comfy Deploy programmatically to trigger run and retrieve output",
},
});
app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
type: "apiKey",
bearerFormat: "JWT",
in: "header",
name: "Authorization",
description:
"API token created in Comfy Deploy <a href='/api-keys' target='_blank' style='text-decoration: underline;'>/api-keys</a>",
});
const handler = handle(app);
export const GET = handler;
export const POST = handler;
-167
View File
@@ -1,167 +0,0 @@
import { parseDataSafe } from "../../../../lib/parseDataSafe";
import { createRun } from "../../../../server/createRun";
import { db } from "@/db/db";
import { deploymentsTable } from "@/db/schema";
import { isKeyRevoked } from "@/server/curdApiKeys";
import { getRunsData } from "@/server/getRunsOutput";
import { parseJWT } from "@/server/parseJWT";
import { replaceCDNUrl } from "@/server/replaceCDNUrl";
import { eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
export const dynamic = "force-dynamic";
const Request = z.object({
deployment_id: z.string(),
inputs: z.record(z.string()).optional(),
});
const Request2 = z.object({
run_id: z.string(),
});
async function checkToken(request: Request) {
const token = request.headers.get("Authorization")?.split(" ")?.[1]; // Assuming token is sent as "Bearer your_token"
const userData = token ? parseJWT(token) : undefined;
if (!userData || token === undefined) {
return {
error: new NextResponse("Invalid or expired token", {
status: 401,
}),
};
} else {
const revokedKey = await isKeyRevoked(token);
if (revokedKey)
return {
error: new NextResponse("Revoked token", {
status: 401,
}),
};
}
return {
data: userData,
};
}
export async function GET(request: Request) {
const apiKeyTokenData = await checkToken(request);
if (apiKeyTokenData.error) return apiKeyTokenData.error;
const [data, error] = await parseDataSafe(Request2, request);
if (!data || error) return error;
// return NextResponse.json(
// await db
// .select()
// .from(workflowTable)
// .innerJoin(
// workflowRunsTable,
// eq(workflowTable.id, workflowRunsTable.workflow_id)
// )
// .where(
// and(
// eq(workflowTable.id, workflowRunsTable.workflow_id),
// apiKeyTokenData.data.org_id
// ? eq(workflowTable.org_id, apiKeyTokenData.data.org_id)
// : eq(workflowTable.user_id, apiKeyTokenData.data.user_id!)
// )
// ),
// {
// status: 200,
// }
// );
const run = await getRunsData(apiKeyTokenData.data, data.run_id);
if (!run) return new NextResponse("Run not found", { status: 404 });
if (run?.status === "success" && run?.outputs?.length > 0) {
for (let i = 0; i < run.outputs.length; i++) {
const output = run.outputs[i];
if (output.data?.images !== undefined) {
for (let j = 0; j < output.data?.images.length; j++) {
const element = output.data?.images[j];
element.url = replaceCDNUrl(
`${process.env.SPACES_ENDPOINT}/${process.env.SPACES_BUCKET}/outputs/runs/${run.id}/${element.filename}`
);
}
} else if (output.data?.files !== undefined) {
for (let j = 0; j < output.data?.files.length; j++) {
const element = output.data?.files[j];
element.url = replaceCDNUrl(
`${process.env.SPACES_ENDPOINT}/${process.env.SPACES_BUCKET}/outputs/runs/${run.id}/${element.filename}`
);
}
}
}
}
return NextResponse.json(run, {
status: 200,
});
}
export async function POST(request: Request) {
const apiKeyTokenData = await checkToken(request);
if (apiKeyTokenData.error) return apiKeyTokenData.error;
const [data, error] = await parseDataSafe(Request, request);
if (!data || error) return error;
const origin = new URL(request.url).origin;
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.data,
});
if ("error" in run_id) throw new Error(run_id.error);
return NextResponse.json(
{
run_id: "workflow_run_id" in run_id ? run_id.workflow_run_id : "",
},
{
status: 200,
}
);
} catch (error: any) {
return NextResponse.json(
{
error: error.message,
},
{
status: 500,
}
);
}
}
@@ -0,0 +1,91 @@
export const metadata = {
title: 'Workflow API',
description:
'Get started with API integration to run any deploy ComfyUI workflow',
}
{/* # Workflow API */}
{/* Get started with API integration to run any deploy ComfyUI workflow */}
<SwaggerUI url={"/api/doc"}></SwaggerUI>
{/* ## Trigger a run {{ tag: 'POST', label: '/api/run' }}
<Row>
<Col>
Trigger a run with a deployment id
### Optional attributes
<Properties>
<Property name="conversation_id" type="string">
Limit to attachments from a given conversation.
</Property>
<Property name="limit" type="integer">
Limit the number of attachments returned.
</Property>
</Properties>
</Col>
<Col sticky>
<CodeGroup title="Request" tag="GET" label="/v1/attachments">
```bash {{ title: 'cURL' }}
curl -G https://api.protocol.chat/v1/attachments \
-H "Authorization: Bearer {token}" \
-d conversation_id="xgQQXg3hrtjh7AvZ" \
-d limit=10
```
```js
import ApiClient from '@example/protocol-api'
const client = new ApiClient(token)
await client.attachments.list()
```
```python
from protocol_api import ApiClient
client = ApiClient(token)
client.attachments.list()
```
```php
$client = new \Protocol\ApiClient($token);
$client->attachments->list();
```
</CodeGroup>
```json {{ title: 'Response' }}
{
"has_more": false,
"data": [
{
"id": "Nc6yKKMpcxiiFxp6",
"message_id": "LoPsJaMcPBuFNjg1",
"filename": "Invoice_room_service__Plaza_Hotel.pdf",
"file_url": "https://assets.protocol.chat/attachments/Invoice_room_service__Plaza_Hotel.pdf",
"file_type": "application/pdf",
"file_size": 21352,
"created_at": 692233200
},
{
"id": "hSIhXBhNe8X1d8Et"
// ...
}
]
}
```
</Col>
</Row>
--- */}