CMU Coding Bootcamp
at main 410 lines 11 kB view raw
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;