Skip to content

Commit 42b40eb

Browse files
authored
REST API: Notebook creation (#474)
* Add notebook API endpoints - POST /api/notebooks - Create notebooks with API key auth - GET /api/notebooks/:id - Get notebook by ID with permissions - Uses permissions provider architecture - Zod validation for request bodies - Proper error handling and auth * Add tag support to notebook creation API - POST /api/notebooks now accepts optional tags array - Tags are created automatically if they don't exist (default blue color) - Existing tags are reused for the same user - GET /api/notebooks/:id returns tags in response - Proper validation for tag names (1-50 characters) - Graceful error handling - notebook creation succeeds even if tag assignment fails
1 parent 6cb79c9 commit 42b40eb

File tree

4 files changed

+230
-1
lines changed

4 files changed

+230
-1
lines changed

backend/routes.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Hono } from "hono";
22
import { v4 as uuidv4 } from "uuid";
3+
import { z } from "zod";
34
import { authMiddleware, type AuthContext } from "./middleware.ts";
45
import { type Env } from "./types.ts";
56

@@ -10,6 +11,19 @@ import {
1011
validateProviderConfig,
1112
} from "./providers/api-key-factory.ts";
1213

14+
// Import notebook utilities
15+
import { createNotebookId } from "./utils/notebook-id.ts";
16+
import {
17+
createNotebook,
18+
getNotebookById,
19+
createTag,
20+
getTagByName,
21+
assignTagToNotebook,
22+
getNotebookTags,
23+
} from "./trpc/db.ts";
24+
import { createPermissionsProvider } from "./notebook-permissions/factory.ts";
25+
import type { TagColor } from "./trpc/types.ts";
26+
1327
const api = new Hono<{ Bindings: Env; Variables: AuthContext }>();
1428

1529
// Health endpoint - no auth required
@@ -52,6 +66,218 @@ api.get("/me", authMiddleware, (c) => {
5266
});
5367
});
5468

69+
// Request body validation schema for notebook creation
70+
const createNotebookSchema = z.object({
71+
title: z.string().min(1).max(255).trim(),
72+
tags: z.array(z.string().min(1).max(50).trim()).optional(),
73+
});
74+
75+
/**
76+
* POST /notebooks - Create a new notebook
77+
*
78+
* Creates a new notebook with the authenticated user as owner.
79+
* Designed for external clients using API keys.
80+
*
81+
* @param title - Required notebook title (1-255 characters)
82+
* @param tags - Optional array of tag names to assign to notebook
83+
* @returns Created notebook with ID, title, owner, and timestamps
84+
*/
85+
api.post("/notebooks", authMiddleware, async (c) => {
86+
const passport = c.get("passport");
87+
if (!passport) {
88+
return c.json({ error: "Authentication failed" }, 401);
89+
}
90+
91+
try {
92+
const body = await c.req.json();
93+
94+
// Validate request body with Zod
95+
const parseResult = createNotebookSchema.safeParse(body);
96+
if (!parseResult.success) {
97+
return c.json(
98+
{
99+
error: "Bad Request",
100+
message: "Invalid request body",
101+
details: parseResult.error.format(),
102+
},
103+
400
104+
);
105+
}
106+
107+
const { title, tags } = parseResult.data;
108+
109+
// Generate notebook ID
110+
const notebookId = createNotebookId();
111+
112+
// Create notebook in database - ownership is established through owner_id field
113+
const success = await createNotebook(c.env.DB, {
114+
id: notebookId,
115+
ownerId: passport.user.id,
116+
title: title,
117+
});
118+
119+
if (!success) {
120+
return c.json(
121+
{
122+
error: "Internal Server Error",
123+
message: "Failed to create notebook",
124+
},
125+
500
126+
);
127+
}
128+
129+
// Retrieve the created notebook
130+
const notebook = await getNotebookById(c.env.DB, notebookId);
131+
132+
if (!notebook) {
133+
return c.json(
134+
{
135+
error: "Internal Server Error",
136+
message: "Notebook created but could not be retrieved",
137+
},
138+
500
139+
);
140+
}
141+
142+
// Handle tag assignment if tags were provided
143+
if (tags && tags.length > 0) {
144+
try {
145+
for (const tagName of tags) {
146+
// Check if tag already exists for this user
147+
let tag = await getTagByName(c.env.DB, tagName, passport.user.id);
148+
149+
// Create tag if it doesn't exist
150+
if (!tag) {
151+
tag = await createTag(c.env.DB, {
152+
name: tagName,
153+
color: "#3B82F6" as TagColor, // Default blue color
154+
user_id: passport.user.id,
155+
});
156+
}
157+
158+
// Assign tag to notebook if creation was successful
159+
if (tag) {
160+
await assignTagToNotebook(c.env.DB, notebookId, tag.id);
161+
}
162+
}
163+
} catch (tagError) {
164+
console.warn("❌ Tag assignment failed:", tagError);
165+
// Don't fail the entire request if tag assignment fails
166+
}
167+
}
168+
169+
return c.json({
170+
id: notebook.id,
171+
title: notebook.title,
172+
ownerId: notebook.owner_id,
173+
createdAt: notebook.created_at,
174+
updatedAt: notebook.updated_at,
175+
});
176+
} catch (error) {
177+
console.error("❌ Notebook creation failed:", error);
178+
179+
if (error instanceof SyntaxError) {
180+
return c.json(
181+
{
182+
error: "Bad Request",
183+
message: "Invalid JSON in request body",
184+
},
185+
400
186+
);
187+
}
188+
189+
return c.json(
190+
{
191+
error: "Internal Server Error",
192+
message: "Failed to create notebook",
193+
},
194+
500
195+
);
196+
}
197+
});
198+
199+
/**
200+
* GET /notebooks/:id - Get specific notebook by ID
201+
*
202+
* Returns notebook details if the authenticated user has access.
203+
* Designed for external clients using API keys.
204+
*
205+
* @param id - Notebook ID
206+
* @returns Notebook details with metadata
207+
*/
208+
api.get("/notebooks/:id", authMiddleware, async (c) => {
209+
const passport = c.get("passport");
210+
if (!passport) {
211+
return c.json({ error: "Authentication failed" }, 401);
212+
}
213+
214+
const notebookId = c.req.param("id");
215+
if (!notebookId) {
216+
return c.json(
217+
{
218+
error: "Bad Request",
219+
message: "Notebook ID is required",
220+
},
221+
400
222+
);
223+
}
224+
225+
try {
226+
// Create permissions provider
227+
const permissionsProvider = createPermissionsProvider(c.env);
228+
229+
// Check if user has access to this notebook
230+
const permissionResult = await permissionsProvider.checkPermission(
231+
passport.user.id,
232+
notebookId
233+
);
234+
235+
if (!permissionResult.hasAccess) {
236+
return c.json(
237+
{
238+
error: "Not Found",
239+
message: "Notebook not found or access denied",
240+
},
241+
404
242+
);
243+
}
244+
245+
// Get notebook from database
246+
const notebook = await getNotebookById(c.env.DB, notebookId);
247+
248+
if (!notebook) {
249+
return c.json(
250+
{
251+
error: "Not Found",
252+
message: "Notebook not found",
253+
},
254+
404
255+
);
256+
}
257+
258+
// Get notebook tags
259+
const tags = await getNotebookTags(c.env.DB, notebookId, passport.user.id);
260+
261+
return c.json({
262+
id: notebook.id,
263+
title: notebook.title,
264+
ownerId: notebook.owner_id,
265+
createdAt: notebook.created_at,
266+
updatedAt: notebook.updated_at,
267+
tags: tags,
268+
});
269+
} catch (error) {
270+
console.error("❌ Failed to get notebook:", error);
271+
return c.json(
272+
{
273+
error: "Internal Server Error",
274+
message: "Failed to retrieve notebook",
275+
},
276+
500
277+
);
278+
}
279+
});
280+
55281
// Mount unified API key routes
56282
api.route("/api-keys", apiKeyRoutes);
57283

backend/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
IncomingRequestCfProperties,
1313
ExportedHandlerFetchHandler,
1414
} from "@cloudflare/workers-types";
15+
1516
// N.B. it's important that we pull in all the types directly from @cloudflare/workers-types
1617
// because we are NOT adding @cloudflare/workers-types to the types[] field in tsconfig.json
1718
// This means that e.g. the global Request and Response objects are not correct

tsconfig.test.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"jsx": "react-jsx",
1919
"baseUrl": ".",
2020
"paths": {
21-
"@/*": ["./src/*"]
21+
"@/*": ["./src/*"],
22+
"backend/*": ["./backend/*"]
2223
}
2324
},
2425
"include": ["test/**/*"],

vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export default defineConfig({
7373
resolve: {
7474
alias: {
7575
"@": path.resolve(__dirname, "./src"),
76+
backend: path.resolve(__dirname, "./backend"),
7677
"@anode/web-client": path.resolve(__dirname, "./packages/web-client/src"),
7778
"@anode/docworker": path.resolve(__dirname, "./packages/docworker/src"),
7879
"@anode/pyodide-runtime-agent": path.resolve(

0 commit comments

Comments
 (0)