CMU Coding Bootcamp
1import express, {
2 type NextFunction,
3 type Request,
4 type Response,
5} from "express";
6import { writeFile, exists, readFile } from "fs/promises";
7
8const uuidv7 = /^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$/;
9
10interface Comment {
11 id: string;
12 author: string;
13 content: string;
14}
15
16interface Post {
17 id: string;
18 title: string;
19 author: string;
20 publicationDate: number;
21 readTime: number;
22 content: string;
23 likes: number;
24 tags: string[];
25 comments: Comment[];
26}
27
28const getPosts: () => Promise<Post[]> = async () => {
29 if (!(await exists("posts.json"))) {
30 await writeFile("posts.json", JSON.stringify([]));
31 }
32 const file = await readFile("posts.json", "utf-8");
33 if (file.length === 0) {
34 return [];
35 }
36 return JSON.parse(file);
37};
38
39const updatePost = async (post: Post): Promise<void> => {
40 const posts = await getPosts();
41 const index = posts.findIndex((t) => t.id === post.id);
42 if (index !== -1) {
43 posts[index] = post;
44 } else {
45 posts.push(post);
46 }
47 await writeFile("posts.json", JSON.stringify(posts));
48};
49
50const deletePost = async (id: string): Promise<void> => {
51 const posts = await getPosts();
52 const index = posts.findIndex((t) => t.id === id);
53 if (index !== -1) {
54 posts.splice(index, 1);
55 await writeFile("posts.json", JSON.stringify(posts));
56 }
57};
58
59const keyTypes = {
60 title: "string",
61 author: "string",
62 content: "string",
63};
64
65class BadDataIssues extends Error {
66 constructor(
67 public missingKeys: string[],
68 public extraKeys: string[],
69 public badValues: [string, string][],
70 ) {
71 super("Bad data issues");
72 }
73}
74
75const validateUpdate = (post: Record<string, any>) => {
76 let missingKeys = ["title", "author", "content", "tags"].filter(
77 (key) => !Object.keys(post).includes(key),
78 );
79 let extraKeys = Object.keys(post).filter(
80 (key) => !["title", "author", "content", "tags"].includes(key),
81 );
82 let badValues = Object.entries(post)
83 .filter(([key, value]) => {
84 if (key === "title") return typeof value !== "string";
85 if (key === "author") return typeof value !== "string";
86 if (key === "content") return typeof value !== "string";
87 if (key === "tags")
88 return (
89 !Array.isArray(value) ||
90 !value.every((tag) => typeof tag === "string")
91 );
92 return false;
93 })
94 .map(
95 ([key, _value]) =>
96 [key, keyTypes[key as keyof typeof keyTypes]] as [string, string],
97 );
98 if (missingKeys.length > 0 || extraKeys.length > 0 || badValues.length > 0) {
99 throw new BadDataIssues(missingKeys, extraKeys, badValues);
100 }
101 return post as Pick<Post, "title" | "author" | "content" | "tags">;
102};
103
104const validateComment = (comment: Record<string, any>) => {
105 let missingKeys = ["author", "content"].filter(
106 (key) => !Object.keys(comment).includes(key),
107 );
108 let extraKeys = Object.keys(comment).filter(
109 (key) => !["author", "content"].includes(key),
110 );
111 let badValues = Object.entries(comment)
112 .filter(([key, value]) => {
113 if (key === "author") return typeof value !== "string";
114 if (key === "content") return typeof value !== "string";
115 return false;
116 })
117 .map(
118 ([key, _value]) =>
119 [key, keyTypes[key as keyof typeof keyTypes]] as [string, string],
120 );
121 if (missingKeys.length > 0 || extraKeys.length > 0 || badValues.length > 0) {
122 throw new BadDataIssues(missingKeys, extraKeys, badValues);
123 }
124 return comment as Pick<Comment, "author" | "content">;
125};
126
127enum ErrorType {
128 BadData,
129 MalformedID,
130 NotFound,
131 CNotFound,
132 InvalidQuery,
133}
134
135class PostError extends Error {
136 status: number;
137 constructor(type: ErrorType) {
138 let msg: string;
139 let st: number;
140 switch (type) {
141 case ErrorType.BadData:
142 msg = "Bad data";
143 st = 400;
144 break;
145 case ErrorType.NotFound:
146 msg = "Post not found";
147 st = 404;
148 break;
149 case ErrorType.MalformedID:
150 msg = "Malformed ID (should be a UUIDv7)";
151 st = 400;
152 break;
153 case ErrorType.CNotFound:
154 msg = "Comment not found";
155 st = 404;
156 break;
157 case ErrorType.InvalidQuery:
158 msg = "Invalid query";
159 st = 400;
160 break;
161 default:
162 msg = "Unknown error";
163 st = 500;
164 }
165 super(msg);
166 this.name = "PostError";
167 this.status = st;
168 }
169}
170
171const errorHandler = (
172 err: Error,
173 _req: Request,
174 res: Response,
175 _next: NextFunction,
176) => {
177 if (err instanceof PostError) {
178 let msg = err.message.replace("{{id}}", res.locals.id ?? "");
179
180 let obj: Map<string, any> = new Map<string, any>([
181 ["error", `${err.name}: ${msg}`],
182 ]);
183
184 if (res.locals.bdi) {
185 if (res.locals.bdi.missingKeys.length > 0) {
186 obj.set("missingKeys", res.locals.bdi.missingKeys);
187 }
188 if (res.locals.bdi.extraKeys.length > 0) {
189 obj.set("extraKeys", res.locals.bdi.extraKeys);
190 }
191 if (res.locals.bdi.badValues.length > 0) {
192 obj.set("badValues", res.locals.bdi.badValues);
193 }
194 }
195
196 res.status(err.status).json(Object.fromEntries(obj.entries()));
197 } else {
198 console.error(err.stack);
199 res.status(500).json({ error: "Internal Server Error" });
200 }
201};
202
203const router = express.Router();
204
205router.use((req, _res, next) => {
206 console.log(`Recieved ${req.method} request to ${req.url}`);
207 next();
208});
209
210router.get("/", async (_req, res) => {
211 const posts = await getPosts();
212 res.json(posts);
213});
214
215router.post("/", async (req, res) => {
216 try {
217 const details = validateUpdate(req.body);
218 const post: Post = {
219 id: Bun.randomUUIDv7(),
220 publicationDate: Date.now(),
221 likes: 0,
222 comments: [],
223 readTime: Math.ceil(details.content.length / 200),
224 ...details,
225 };
226 await updatePost(post);
227 res.json(post);
228 } catch (err) {
229 if (err instanceof BadDataIssues) {
230 res.locals.bdi = err;
231 throw new PostError(ErrorType.BadData);
232 } else {
233 throw err;
234 }
235 }
236});
237
238router.put("/:id", async (req, res) => {
239 res.locals.id = req.params.id;
240 if (!uuidv7.test(res.locals.id)) {
241 throw new PostError(ErrorType.MalformedID);
242 }
243 const posts = await getPosts();
244 const post = posts.find((p) => p.id === res.locals.id);
245 if (!post) throw new PostError(ErrorType.NotFound);
246 try {
247 const details = validateUpdate(req.body);
248 post.title = details.title;
249 post.author = details.author;
250 post.content = details.content;
251 post.readTime = Math.ceil(details.content.length / 200);
252 await updatePost(post);
253 res.json(post);
254 } catch (err) {
255 if (err instanceof BadDataIssues) {
256 res.locals.bdi = err;
257 throw new PostError(ErrorType.BadData);
258 } else {
259 throw err;
260 }
261 }
262});
263
264router.delete("/:id", async (req, res) => {
265 res.locals.id = req.params.id;
266 if (!uuidv7.test(res.locals.id)) {
267 throw new PostError(ErrorType.MalformedID);
268 }
269 const posts = await getPosts();
270 const post = posts.find((p) => p.id === res.locals.id);
271 if (!post) throw new PostError(ErrorType.NotFound);
272 await deletePost(res.locals.id);
273 res.json(post);
274});
275
276router.post("/:id/like", async (req, res) => {
277 res.locals.id = req.params.id;
278 if (!uuidv7.test(res.locals.id)) {
279 throw new PostError(ErrorType.MalformedID);
280 }
281 const posts = await getPosts();
282 const post = posts.find((p) => p.id === res.locals.id);
283 if (!post) throw new PostError(ErrorType.NotFound);
284 post.likes++;
285 await updatePost(post);
286 res.json({ likes: post.likes });
287});
288
289router.post("/:id/comment", async (req, res) => {
290 res.locals.id = req.params.id;
291 if (!uuidv7.test(res.locals.id)) {
292 throw new PostError(ErrorType.MalformedID);
293 }
294 const posts = await getPosts();
295 const post = posts.find((p) => p.id === res.locals.id);
296 if (!post) throw new PostError(ErrorType.NotFound);
297 try {
298 const details = validateComment(req.body);
299 const comment: Comment = {
300 id: Bun.randomUUIDv7(),
301 ...details,
302 };
303 post.comments.push(comment);
304 await updatePost(post);
305 res.json(post);
306 } catch (err) {
307 if (err instanceof BadDataIssues) {
308 res.locals.bdi = err;
309 throw new PostError(ErrorType.BadData);
310 } else {
311 throw err;
312 }
313 }
314});
315
316router.get("/:id/comments", async (req, res) => {
317 res.locals.id = req.params.id;
318 if (!uuidv7.test(res.locals.id)) {
319 throw new PostError(ErrorType.MalformedID);
320 }
321 const posts = await getPosts();
322 const post = posts.find((p) => p.id === res.locals.id);
323 if (!post) throw new PostError(ErrorType.NotFound);
324 res.json(post.comments);
325});
326
327router.put("/comments/:id", async (req, res) => {
328 res.locals.cid = req.params.id;
329 if (!uuidv7.test(res.locals.id)) {
330 throw new PostError(ErrorType.MalformedID);
331 }
332 const posts = await getPosts();
333 const post = posts.find((p) =>
334 p.comments.find((c) => c.id === res.locals.id),
335 );
336 if (!post) throw new PostError(ErrorType.CNotFound);
337 const comment = post.comments.find((c) => c.id === res.locals.id);
338 if (!comment) throw new PostError(ErrorType.CNotFound);
339 try {
340 const details = validateComment(req.body);
341 Object.assign(comment, details);
342 await updatePost(post);
343 res.json(comment);
344 } catch (err) {
345 if (err instanceof BadDataIssues) {
346 res.locals.bdi = err;
347 throw new PostError(ErrorType.BadData);
348 } else {
349 throw err;
350 }
351 }
352});
353
354router.delete("/comments/:id", async (req, res) => {
355 res.locals.cid = req.params.id;
356 if (!uuidv7.test(res.locals.id)) {
357 throw new PostError(ErrorType.MalformedID);
358 }
359 const posts = await getPosts();
360 const post = posts.find((p) =>
361 p.comments.find((c) => c.id === res.locals.id),
362 );
363 if (!post) throw new PostError(ErrorType.CNotFound);
364 const comment = post.comments.find((c) => c.id === res.locals.id);
365 if (!comment) throw new PostError(ErrorType.CNotFound);
366 post.comments = post.comments.filter((c) => c.id !== res.locals.id);
367 await updatePost(post);
368 res.json(comment);
369});
370
371router.get("/search", async (req, res) => {
372 const query = req.query.q?.toString();
373 if (!query) {
374 throw new PostError(ErrorType.InvalidQuery);
375 }
376 const posts = await getPosts();
377
378 const filteredPosts = posts.filter((post) => {
379 const titleMatch = post.title.includes(query);
380 const contentMatch = post.content.includes(query);
381 return titleMatch && contentMatch;
382 });
383
384 res.json(filteredPosts);
385});
386
387router.get("/filter", async (req, res) => {
388 const filter = req.query as Partial<{ author: string; tag: string }>;
389 if (!filter) {
390 throw new PostError(ErrorType.InvalidQuery);
391 }
392 const posts = await getPosts();
393
394 const filteredPosts = posts.filter((post) => {
395 const authorMatch = filter.author
396 ? post.author.includes(filter.author)
397 : true;
398 const tagsMatch = filter.tag
399 ? post.tags.some((tag) => tag === filter.tag)
400 : true;
401
402 return authorMatch && tagsMatch;
403 });
404
405 res.json(filteredPosts);
406});
407
408router.use(errorHandler);
409
410export default router;