+5
-5
server/books.ts
+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
+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
+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;