Skip to content

Commit 8207e9f

Browse files
committed
close the HTTP contract loop
Change-Id: I653d759a826b23b7d023b9639ba9d76da7d3553f
1 parent 06f8527 commit 8207e9f

21 files changed

Lines changed: 6031 additions & 481 deletions

File tree

apps/server/openapi.json

Lines changed: 2628 additions & 222 deletions
Large diffs are not rendered by default.

apps/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"start": "bun run src/index.ts",
1313
"test": "bun test",
1414
"typecheck": "tsc --noEmit",
15-
"gen:openapi": "bun run scripts/emit-openapi.ts"
15+
"gen:openapi": "bun run scripts/emit-openapi.ts && biome format --write openapi.json"
1616
},
1717
"dependencies": {
1818
"@hono/zod-openapi": "^1.4.0",

apps/server/scripts/emit-openapi.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ if (!res.ok) {
66
}
77

88
const doc = await res.json();
9-
const output = `${JSON.stringify(doc, null, 2)}\n`;
9+
const output = `${JSON.stringify(doc, null, "\t")}\n`;
1010
await Bun.write(new URL("../openapi.json", import.meta.url), output);
1111

1212
console.log("Wrote apps/server/openapi.json");

apps/server/src/routes/files.ts

Lines changed: 126 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
import { OpenAPIHono } from "@hono/zod-openapi";
2-
import { jsonError } from "@/lib/http-error";
1+
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
2+
import { ErrorResponseSchema, errorResponses } from "@/schemas/common";
3+
import {
4+
FileDownloadResponseSchema,
5+
FileParamsSchema,
6+
FileStatusesBodySchema,
7+
FileStatusesResponseSchema,
8+
FilesResponseSchema,
9+
ProviderFileInfoSchema,
10+
UploadFileFormSchema,
11+
} from "@/schemas/files";
312
import {
413
deleteUserFile,
514
getUserFileDownloadUrl,
@@ -13,109 +22,135 @@ export const filesRoute = new OpenAPIHono();
1322
// Max upload size enforced at the edge (the provider also caps via nacos cmaMaxFileSize).
1423
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
1524

16-
/**
17-
* POST /api/files — multipart/form-data with field `file`. Streams the bytes to the provider's
18-
* Files API via the SDK and returns the file metadata ({ id, filename, mime_type, size_bytes, created_at }).
19-
* Plain handler (not zod-openapi) so multipart parsing stays under our control.
20-
*/
21-
filesRoute.post("/files", async (c) => {
22-
let body: Record<string, unknown>;
23-
try {
24-
body = await c.req.parseBody();
25-
} catch (error) {
26-
return jsonError(error, 400);
27-
}
28-
const file = body.file;
29-
if (!(file instanceof File) || file.size === 0) {
30-
return c.json({ error: { message: "file is required" } }, 400);
31-
}
32-
if (file.size > MAX_UPLOAD_BYTES) {
33-
return c.json({ error: { message: "file too large" } }, 413);
34-
}
25+
const uploadFileRoute = createRoute({
26+
method: "post",
27+
path: "/files",
28+
operationId: "uploadFile",
29+
request: {
30+
body: {
31+
required: true,
32+
content: { "multipart/form-data": { schema: UploadFileFormSchema } },
33+
},
34+
},
35+
responses: {
36+
201: {
37+
description: "Upload a workspace file",
38+
content: { "application/json": { schema: ProviderFileInfoSchema } },
39+
},
40+
413: {
41+
description: "File exceeds the upload size limit",
42+
content: { "application/json": { schema: ErrorResponseSchema } },
43+
},
44+
...errorResponses,
45+
},
46+
});
47+
48+
filesRoute.openapi(
49+
uploadFileRoute,
50+
async (c) => {
51+
const { file } = c.req.valid("form");
52+
if (file.size === 0) {
53+
return c.json({ error: { message: "file is required" } }, 400);
54+
}
55+
if (file.size > MAX_UPLOAD_BYTES) {
56+
return c.json({ error: { message: "file too large" } }, 413);
57+
}
3558

36-
try {
3759
const content = new Uint8Array(await file.arrayBuffer());
3860
const info = await uploadUserFile({
3961
content,
4062
filename: file.name || "upload",
4163
mimeType: file.type || undefined,
4264
});
4365
return c.json(info, 201);
44-
} catch (error) {
45-
return jsonError(error);
46-
}
66+
},
67+
(result, c) => {
68+
if (!result.success) return c.json({ error: { message: "file is required" } }, 400);
69+
},
70+
);
71+
72+
const listFilesRoute = createRoute({
73+
method: "get",
74+
path: "/files",
75+
operationId: "listFiles",
76+
responses: {
77+
200: {
78+
description: "List workspace files",
79+
content: { "application/json": { schema: FilesResponseSchema } },
80+
},
81+
...errorResponses,
82+
},
4783
});
4884

49-
/**
50-
* GET /api/files — list workspace user-uploaded files (newest first), as
51-
* `{ files: [{ id, filename, mime_type, size_bytes, created_at, downloadable, status?, available }] }`.
52-
* The OpenAPI list omits `status` (@JsonIgnore) but carries `downloadable`; the server enriches
53-
* `status` + `available` via @openagentpack/sdk/file-lifecycle before responding.
54-
*/
55-
filesRoute.get("/files", async (c) => {
56-
try {
57-
const files = await listUserFiles();
58-
return c.json({ files });
59-
} catch (error) {
60-
return jsonError(error);
61-
}
85+
filesRoute.openapi(listFilesRoute, async (c) => {
86+
const files = await listUserFiles();
87+
return c.json({ files }, 200);
88+
});
89+
90+
const getFileStatusesRoute = createRoute({
91+
method: "post",
92+
path: "/files/status",
93+
operationId: "getFileStatuses",
94+
request: {
95+
body: {
96+
required: true,
97+
content: { "application/json": { schema: FileStatusesBodySchema } },
98+
},
99+
},
100+
responses: {
101+
200: {
102+
description: "Get file scan statuses",
103+
content: { "application/json": { schema: FileStatusesResponseSchema } },
104+
},
105+
...errorResponses,
106+
},
107+
});
108+
109+
filesRoute.openapi(
110+
getFileStatusesRoute,
111+
async (c) => {
112+
const { fileIds } = c.req.valid("json");
113+
const files = await getUserFileStatuses(fileIds);
114+
return c.json({ files }, 200);
115+
},
116+
(result, c) => {
117+
if (!result.success) return c.json({ error: { message: "fileIds (string[]) is required" } }, 400);
118+
},
119+
);
120+
121+
const downloadFileRoute = createRoute({
122+
method: "get",
123+
path: "/files/{id}/download",
124+
operationId: "downloadFile",
125+
request: { params: FileParamsSchema },
126+
responses: {
127+
200: {
128+
description: "Resolve a short-lived file download URL",
129+
content: { "application/json": { schema: FileDownloadResponseSchema } },
130+
},
131+
...errorResponses,
132+
},
62133
});
63134

64-
/**
65-
* POST /api/files/status — body `{ fileIds: string[] }`. Returns each file's normalized scan
66-
* `status` and bindability (`{ files: [{ id, status, available }] }`).
67-
*/
68-
filesRoute.post("/files/status", async (c) => {
69-
let body: { fileIds?: unknown };
70-
try {
71-
body = await c.req.json();
72-
} catch (error) {
73-
return jsonError(error, 400);
74-
}
75-
const fileIds = body.fileIds;
76-
if (!Array.isArray(fileIds) || fileIds.some((id) => typeof id !== "string")) {
77-
return c.json({ error: { message: "fileIds (string[]) is required" } }, 400);
78-
}
79-
try {
80-
const files = await getUserFileStatuses(fileIds as string[]);
81-
return c.json({ files });
82-
} catch (error) {
83-
return jsonError(error);
84-
}
135+
filesRoute.openapi(downloadFileRoute, async (c) => {
136+
const { id } = c.req.valid("param");
137+
const result = await getUserFileDownloadUrl(id);
138+
return c.json(result, 200);
85139
});
86140

87-
/**
88-
* GET /api/files/:id/download — resolves a short-lived presigned download URL for a file
89-
* (typically an agent-delivered artifact), as `{ url, expires_at }`. The webui fetches this on
90-
* demand (each click), so the URL never goes stale in the UI. Errors if the provider has no
91-
* download endpoint (bailian/claude).
92-
*/
93-
filesRoute.get("/files/:id/download", async (c) => {
94-
const id = c.req.param("id");
95-
if (!id) {
96-
return c.json({ error: { message: "file id is required" } }, 400);
97-
}
98-
try {
99-
const result = await getUserFileDownloadUrl(id);
100-
return c.json(result);
101-
} catch (error) {
102-
return jsonError(error);
103-
}
141+
const deleteFileRoute = createRoute({
142+
method: "delete",
143+
path: "/files/{id}",
144+
operationId: "deleteFile",
145+
request: { params: FileParamsSchema },
146+
responses: {
147+
204: { description: "File deleted" },
148+
...errorResponses,
149+
},
104150
});
105151

106-
/**
107-
* DELETE /api/files/:id — removes a previously uploaded file from the provider's Files API.
108-
* Used when the composer's upload chip is dismissed so the backend doesn't accumulate orphans.
109-
*/
110-
filesRoute.delete("/files/:id", async (c) => {
111-
const id = c.req.param("id");
112-
if (!id) {
113-
return c.json({ error: { message: "file id is required" } }, 400);
114-
}
115-
try {
116-
await deleteUserFile(id);
117-
return c.body(null, 204);
118-
} catch (error) {
119-
return jsonError(error);
120-
}
152+
filesRoute.openapi(deleteFileRoute, async (c) => {
153+
const { id } = c.req.valid("param");
154+
await deleteUserFile(id);
155+
return c.body(null, 204);
121156
});

0 commit comments

Comments
 (0)