CMU Coding Bootcamp

Compare changes

Choose any two refs to compare.

Changed files
+417 -5
server
+5 -5
server/books.ts
··· 128 128 author: "string", 129 129 }; 130 130 131 - const validateBook = (task: { [key: string]: any }): Book => { 131 + const validateBook = (book: { [key: string]: any }): Book => { 132 132 let missingKeys = ["id", "title", "author"].filter( 133 - (key) => !Object.keys(task).includes(key), 133 + (key) => !Object.keys(book).includes(key), 134 134 ); 135 - let extraKeys = Object.keys(task).filter( 135 + let extraKeys = Object.keys(book).filter( 136 136 (key) => !["id", "title", "author"].includes(key), 137 137 ); 138 - let badValues = Object.entries(task) 138 + let badValues = Object.entries(book) 139 139 .filter(([key, value]) => { 140 140 if (key === "id") return typeof value !== "string" || !ISBN13.test(value); 141 141 if (key === "title") return typeof value !== "string"; ··· 149 149 if (missingKeys.length > 0 || extraKeys.length > 0 || badValues.length > 0) { 150 150 throw new BadDataIssues(missingKeys, extraKeys, badValues); 151 151 } 152 - return task as Book; 152 + return book as Book; 153 153 }; 154 154 155 155 const auth = async (req: Request, res: Response, next: NextFunction) => {
+2
server/index.ts
··· 1 1 import express from "express"; 2 2 import tasks from "./tasks.ts"; 3 3 import books from "./books.ts"; 4 + import posts from "./posts.ts"; 4 5 5 6 const app = express(); 6 7 const port = process.env["NODE_PORT"] ?? 5173; 7 8 8 9 app.use("/tasks", tasks); 9 10 app.use("/books", books); 11 + app.use("/posts", posts); 10 12 11 13 app.listen(port, () => { 12 14 console.log(`Server listening on port ${port}`);
+410
server/posts.ts
··· 1 + import express, { 2 + type NextFunction, 3 + type Request, 4 + type Response, 5 + } from "express"; 6 + import { writeFile, exists, readFile } from "fs/promises"; 7 + 8 + const uuidv7 = /^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$/; 9 + 10 + interface Comment { 11 + id: string; 12 + author: string; 13 + content: string; 14 + } 15 + 16 + interface 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 + 28 + const 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 + 39 + const 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 + 50 + const 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 + 59 + const keyTypes = { 60 + title: "string", 61 + author: "string", 62 + content: "string", 63 + }; 64 + 65 + class 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 + 75 + const 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 + 104 + const 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 + 127 + enum ErrorType { 128 + BadData, 129 + MalformedID, 130 + NotFound, 131 + CNotFound, 132 + InvalidQuery, 133 + } 134 + 135 + class 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 + 171 + const 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 + 203 + const router = express.Router(); 204 + 205 + router.use((req, _res, next) => { 206 + console.log(`Recieved ${req.method} request to ${req.url}`); 207 + next(); 208 + }); 209 + 210 + router.get("/", async (_req, res) => { 211 + const posts = await getPosts(); 212 + res.json(posts); 213 + }); 214 + 215 + router.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 + 238 + router.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 + 264 + router.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 + 276 + router.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 + 289 + router.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 + 316 + router.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 + 327 + router.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 + 354 + router.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 + 371 + router.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 + 387 + router.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 + 408 + router.use(errorHandler); 409 + 410 + export default router;