feat: add interactive api docs, rewrite run endpoint with hono and zod validator
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
--- */}
|
||||
Reference in New Issue
Block a user