import express, { type NextFunction, type Request, type Response, } from "express"; import { writeFile, exists, readFile } from "fs/promises"; const uuidv7 = /^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$/; interface Comment { id: string; author: string; content: string; } interface Post { id: string; title: string; author: string; publicationDate: number; readTime: number; content: string; likes: number; tags: string[]; comments: Comment[]; } const getPosts: () => Promise = async () => { if (!(await exists("posts.json"))) { await writeFile("posts.json", JSON.stringify([])); } const file = await readFile("posts.json", "utf-8"); if (file.length === 0) { return []; } return JSON.parse(file); }; const updatePost = async (post: Post): Promise => { const posts = await getPosts(); const index = posts.findIndex((t) => t.id === post.id); if (index !== -1) { posts[index] = post; } else { posts.push(post); } await writeFile("posts.json", JSON.stringify(posts)); }; const deletePost = async (id: string): Promise => { const posts = await getPosts(); const index = posts.findIndex((t) => t.id === id); if (index !== -1) { posts.splice(index, 1); await writeFile("posts.json", JSON.stringify(posts)); } }; const keyTypes = { title: "string", author: "string", content: "string", }; class BadDataIssues extends Error { constructor( public missingKeys: string[], public extraKeys: string[], public badValues: [string, string][], ) { super("Bad data issues"); } } const validateUpdate = (post: Record) => { let missingKeys = ["title", "author", "content", "tags"].filter( (key) => !Object.keys(post).includes(key), ); let extraKeys = Object.keys(post).filter( (key) => !["title", "author", "content", "tags"].includes(key), ); let badValues = Object.entries(post) .filter(([key, value]) => { if (key === "title") return typeof value !== "string"; if (key === "author") return typeof value !== "string"; if (key === "content") return typeof value !== "string"; if (key === "tags") return ( !Array.isArray(value) || !value.every((tag) => typeof tag === "string") ); return false; }) .map( ([key, _value]) => [key, keyTypes[key as keyof typeof keyTypes]] as [string, string], ); if (missingKeys.length > 0 || extraKeys.length > 0 || badValues.length > 0) { throw new BadDataIssues(missingKeys, extraKeys, badValues); } return post as Pick; }; const validateComment = (comment: Record) => { let missingKeys = ["author", "content"].filter( (key) => !Object.keys(comment).includes(key), ); let extraKeys = Object.keys(comment).filter( (key) => !["author", "content"].includes(key), ); let badValues = Object.entries(comment) .filter(([key, value]) => { if (key === "author") return typeof value !== "string"; if (key === "content") return typeof value !== "string"; return false; }) .map( ([key, _value]) => [key, keyTypes[key as keyof typeof keyTypes]] as [string, string], ); if (missingKeys.length > 0 || extraKeys.length > 0 || badValues.length > 0) { throw new BadDataIssues(missingKeys, extraKeys, badValues); } return comment as Pick; }; enum ErrorType { BadData, MalformedID, NotFound, CNotFound, InvalidQuery, } class PostError extends Error { status: number; constructor(type: ErrorType) { let msg: string; let st: number; switch (type) { case ErrorType.BadData: msg = "Bad data"; st = 400; break; case ErrorType.NotFound: msg = "Post not found"; st = 404; break; case ErrorType.MalformedID: msg = "Malformed ID (should be a UUIDv7)"; st = 400; break; case ErrorType.CNotFound: msg = "Comment not found"; st = 404; break; case ErrorType.InvalidQuery: msg = "Invalid query"; st = 400; break; default: msg = "Unknown error"; st = 500; } super(msg); this.name = "PostError"; this.status = st; } } const errorHandler = ( err: Error, _req: Request, res: Response, _next: NextFunction, ) => { if (err instanceof PostError) { let msg = err.message.replace("{{id}}", res.locals.id ?? ""); let obj: Map = new Map([ ["error", `${err.name}: ${msg}`], ]); if (res.locals.bdi) { if (res.locals.bdi.missingKeys.length > 0) { obj.set("missingKeys", res.locals.bdi.missingKeys); } if (res.locals.bdi.extraKeys.length > 0) { obj.set("extraKeys", res.locals.bdi.extraKeys); } if (res.locals.bdi.badValues.length > 0) { obj.set("badValues", res.locals.bdi.badValues); } } res.status(err.status).json(Object.fromEntries(obj.entries())); } else { console.error(err.stack); res.status(500).json({ error: "Internal Server Error" }); } }; const router = express.Router(); router.use((req, _res, next) => { console.log(`Recieved ${req.method} request to ${req.url}`); next(); }); router.get("/", async (_req, res) => { const posts = await getPosts(); res.json(posts); }); router.post("/", async (req, res) => { try { const details = validateUpdate(req.body); const post: Post = { id: Bun.randomUUIDv7(), publicationDate: Date.now(), likes: 0, comments: [], readTime: Math.ceil(details.content.length / 200), ...details, }; await updatePost(post); res.json(post); } catch (err) { if (err instanceof BadDataIssues) { res.locals.bdi = err; throw new PostError(ErrorType.BadData); } else { throw err; } } }); router.put("/:id", async (req, res) => { res.locals.id = req.params.id; if (!uuidv7.test(res.locals.id)) { throw new PostError(ErrorType.MalformedID); } const posts = await getPosts(); const post = posts.find((p) => p.id === res.locals.id); if (!post) throw new PostError(ErrorType.NotFound); try { const details = validateUpdate(req.body); post.title = details.title; post.author = details.author; post.content = details.content; post.readTime = Math.ceil(details.content.length / 200); await updatePost(post); res.json(post); } catch (err) { if (err instanceof BadDataIssues) { res.locals.bdi = err; throw new PostError(ErrorType.BadData); } else { throw err; } } }); router.delete("/:id", async (req, res) => { res.locals.id = req.params.id; if (!uuidv7.test(res.locals.id)) { throw new PostError(ErrorType.MalformedID); } const posts = await getPosts(); const post = posts.find((p) => p.id === res.locals.id); if (!post) throw new PostError(ErrorType.NotFound); await deletePost(res.locals.id); res.json(post); }); router.post("/:id/like", async (req, res) => { res.locals.id = req.params.id; if (!uuidv7.test(res.locals.id)) { throw new PostError(ErrorType.MalformedID); } const posts = await getPosts(); const post = posts.find((p) => p.id === res.locals.id); if (!post) throw new PostError(ErrorType.NotFound); post.likes++; await updatePost(post); res.json({ likes: post.likes }); }); router.post("/:id/comment", async (req, res) => { res.locals.id = req.params.id; if (!uuidv7.test(res.locals.id)) { throw new PostError(ErrorType.MalformedID); } const posts = await getPosts(); const post = posts.find((p) => p.id === res.locals.id); if (!post) throw new PostError(ErrorType.NotFound); try { const details = validateComment(req.body); const comment: Comment = { id: Bun.randomUUIDv7(), ...details, }; post.comments.push(comment); await updatePost(post); res.json(post); } catch (err) { if (err instanceof BadDataIssues) { res.locals.bdi = err; throw new PostError(ErrorType.BadData); } else { throw err; } } }); router.get("/:id/comments", async (req, res) => { res.locals.id = req.params.id; if (!uuidv7.test(res.locals.id)) { throw new PostError(ErrorType.MalformedID); } const posts = await getPosts(); const post = posts.find((p) => p.id === res.locals.id); if (!post) throw new PostError(ErrorType.NotFound); res.json(post.comments); }); router.put("/comments/:id", async (req, res) => { res.locals.cid = req.params.id; if (!uuidv7.test(res.locals.id)) { throw new PostError(ErrorType.MalformedID); } const posts = await getPosts(); const post = posts.find((p) => p.comments.find((c) => c.id === res.locals.id), ); if (!post) throw new PostError(ErrorType.CNotFound); const comment = post.comments.find((c) => c.id === res.locals.id); if (!comment) throw new PostError(ErrorType.CNotFound); try { const details = validateComment(req.body); Object.assign(comment, details); await updatePost(post); res.json(comment); } catch (err) { if (err instanceof BadDataIssues) { res.locals.bdi = err; throw new PostError(ErrorType.BadData); } else { throw err; } } }); router.delete("/comments/:id", async (req, res) => { res.locals.cid = req.params.id; if (!uuidv7.test(res.locals.id)) { throw new PostError(ErrorType.MalformedID); } const posts = await getPosts(); const post = posts.find((p) => p.comments.find((c) => c.id === res.locals.id), ); if (!post) throw new PostError(ErrorType.CNotFound); const comment = post.comments.find((c) => c.id === res.locals.id); if (!comment) throw new PostError(ErrorType.CNotFound); post.comments = post.comments.filter((c) => c.id !== res.locals.id); await updatePost(post); res.json(comment); }); router.get("/search", async (req, res) => { const query = req.query.q?.toString(); if (!query) { throw new PostError(ErrorType.InvalidQuery); } const posts = await getPosts(); const filteredPosts = posts.filter((post) => { const titleMatch = post.title.includes(query); const contentMatch = post.content.includes(query); return titleMatch && contentMatch; }); res.json(filteredPosts); }); router.get("/filter", async (req, res) => { const filter = req.query as Partial<{ author: string; tag: string }>; if (!filter) { throw new PostError(ErrorType.InvalidQuery); } const posts = await getPosts(); const filteredPosts = posts.filter((post) => { const authorMatch = filter.author ? post.author.includes(filter.author) : true; const tagsMatch = filter.tag ? post.tags.some((tag) => tag === filter.tag) : true; return authorMatch && tagsMatch; }); res.json(filteredPosts); }); router.use(errorHandler); export default router;