-
Notifications
You must be signed in to change notification settings - Fork 0
/
app.js
321 lines (293 loc) · 13.1 KB
/
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
const helpers = require('./helperFunctions.js'); // import helper functions that help do some repetitive tasks
const fs = require('fs');
const FILE_ERROR_MESSAGE = 'An internal server error occurred within the API while accessing the .json database file. Please try again later.';
const { DateTime } = require('luxon'); // Datetime logic for tracking initial entity creation
const express = require('express');
const app = express();
app.use(express.static('client')); // serve the user the ./client folder with all the .html and .js files for local rendering
app.use(express.json()); // parse request bodies (i.e. req.body) as JSON automatically using express's json middleware function
// == /cards endpoints ==
/**
* @param req {express.Request} Incoming request to API
* @param res {express.Response} Response to be sent to user from API
* @param req.params.id {String | undefined} req id parameter
* @param req.query.ids {String | undefined} req ids query string
*/
app.route('/cards(/:id(\\d+))?') // e.g. GET /cards; /cards/1; /cards/2; /cards?ids=1; /cards?ids=1,2
.get((req, res) => {
// capture id parameter and query string (?ids=...)
const reqParamId = req.params.id;
const reqQueryIds = req.query.ids;
fs.readFile('./serverdb.json', 'utf8', async (err, fileData) => {
if (err) { // if an error occurs while reading the file, return a 500 Internal Server Error and log it to the console for later reference
console.log(err);
res.status(500); // 500 Internal Server Error
res.json({
error: 'database-read-error',
message: FILE_ERROR_MESSAGE
});
} else {
/** @type {{cards: Object[], comments: Object[]}} fileJsonData */
const fileJsonData = JSON.parse(fileData);
// this helper function automatically returns the specified card/s from fileJsonData.cards using the provided request's parameters/queries
const reqCards = helpers.handleIdUrl(fileJsonData.cards, reqParamId, reqQueryIds);
if (reqCards.length === 0 && reqParamId !== undefined) {
// we only want to return a 404 error if a non-existent card was requested specifically by parameter id
// the API schema allows for using the ?ids query to return an empty array if no results were found matching provided id/s
res.status(404); // 404 Not Found
res.json({
error: 'card(s)-not-found',
message: 'No card/s with that/those id/s were found in the database.'
});
} else {
// take this opportunity to update cards' associated reddit data
for (const card of reqCards) {
const redditData = await helpers.getRedditData(card.redditUrl);
if (redditData) { // check redditData is not undefined here just in case the redditUrl associated with the card no longer exists (e.g. comment was deleted)
helpers.updateCardRedditData(card, redditData);
} // if it is indeed undefined, we just leave the data as it as a legacy record instead of removing the card entirely
}
// generate the string of the json data to write back to file using indentation of 2 spaces
const jsonString = JSON.stringify(fileJsonData, null, 2);
fs.writeFile('./serverdb.json', jsonString, 'utf-8', (err) => {
if (err) { // if an error occurs while writing to the file, return a 500 Internal Server Error and log it to the console for later reference
console.log(err);
res.status(500);
res.json({
error: 'database-write-error',
message: FILE_ERROR_MESSAGE
});
} else {
res.status(200); // 200 OK
if (reqParamId !== undefined) { // if a parameter was provided in the url, then the user wants just one specific card so return just the object and not an Array
res.send(reqCards[0]);
} else {
res.send(reqCards); // return the updated array of cards that match the API request
}
}
});
}
}
});
})
.post((req, res) => { // POST /cards
fs.readFile('./serverdb.json', 'utf8', async (err, fileData) => {
if (err) {
console.log(err);
res.status(500);
res.json({
error: 'database-read-error',
message: FILE_ERROR_MESSAGE
});
} else {
const fileJsonData = JSON.parse(fileData);
const newCard = req.body;
// check that the request body provided is actually valid and contains all the expected and required properties
if (!helpers.requestBodyIsValid(newCard, ['title', 'language', 'code', 'redditUrl'], res)) {
return; // abort processing of entity if body invalid
}
// initialise property values
newCard.id = helpers.getNewId(fileJsonData.cards);
newCard.likes = 0;
newCard.time = DateTime.now().toUTC(); // uses current server datetime in UTC
newCard.comments = [];
const urlRegExp = /https:\/\/www.reddit.com\/r\/adventofcode\/comments\/\w+\/comment\/\w+/;
const regExpMatch = newCard.redditUrl.match(urlRegExp); // check the provided redditUrl is in the expected form
let redditData; // initialise to undefined in case no RegExp match
if (regExpMatch) {
newCard.redditUrl = regExpMatch[0]; // first element of match array is whole url that matches the RegExp
redditData = await helpers.getRedditData(newCard.redditUrl);
}
if (!redditData) { // redditData does not exist (e.g. is undefined) - either because no regExpMatch or getRedditData(...) returned undefined
res.status(422); // 422 Unprocessable Entity
res.json({
error: 'reddit-url-failed',
message: "The provided Reddit URL couldn't be resolved. Please check the URL is correct."
});
} else {
// add the newly retrieved (and now confirmed valid) Reddit data to the card object and push this card to the cards array
helpers.updateCardRedditData(newCard, redditData);
fileJsonData.cards.push(newCard);
const jsonString = JSON.stringify(fileJsonData, null, 2);
fs.writeFile('./serverdb.json', jsonString, 'utf-8', (err) => {
if (err) {
console.log(err);
res.status(500);
res.json({
error: 'database-write-error',
message: FILE_ERROR_MESSAGE
});
} else {
res.status(201); // 201 Created
res.json({
message: 'Added new card successfully.',
id: newCard.id
});
}
});
}
}
});
});
app.get('/cards/:id(\\d+)/reddit', async (req, res) => { // e.g. GET /cards/1/reddit; /cards/2/reddit
const reqParamId = req.params.id;
const cards = require('./serverdb.json').cards; // load cards from database
const reqCard = helpers.handleIdUrl(cards, reqParamId, '')[0]; // find the one specific card
if (reqCard) {
// return Reddit data associated with card's redditUrl property
res.json(await helpers.getRedditData(reqCard.redditUrl));
} else { // handleIdUrl(...)[0] is undefined (because card with id reqParamId wasn't found in cards
res.status(404);
res.json({
error: 'card(s)-not-found',
message: `No card matching id ${reqParamId} was found to get Reddit data for.`
});
}
});
// == /comments endpoints ==
app.route('/comments(/:id(\\d+))?') // e.g. GET /comments; /comments/1; /comments/2 /comments?ids=1; /comments?ids=1,2
.get((req, res) => { // many of the same comments as for the /cards endpoints apply for these functions since they provide essentially the same functionality for the different entity
const reqParamId = req.params.id;
const reqQueryIds = req.query.ids;
fs.readFile('./serverdb.json', 'utf8', async (err, fileData) => {
if (err) {
console.log(err);
res.status(500);
res.json({
error: 'database-read-error',
message: FILE_ERROR_MESSAGE
});
} else {
const fileJsonData = JSON.parse(fileData);
const reqComments = helpers.handleIdUrl(fileJsonData.comments, reqParamId, reqQueryIds);
if (reqComments.length === 0 && reqParamId !== undefined) {
res.status(404);
res.json({
error: 'comment(s)-not-found',
message: 'No comment/s with that/those id/s were found in the database.'
});
} else {
res.status(200);
if (reqParamId !== undefined) {
res.send(reqComments[0]);
} else {
res.send(reqComments);
}
}
}
});
})
.post((req, res) => { // POST /comments
fs.readFile('./serverdb.json', 'utf8', async (err, fileData) => {
if (err) {
console.log(err);
res.status(500);
res.json({
error: 'database-read-error',
message: FILE_ERROR_MESSAGE
});
} else {
const fileJsonData = JSON.parse(fileData);
const newComment = req.body;
if (!helpers.requestBodyIsValid(newComment, ['content', 'parent'], res)) {
return;
}
newComment.parent = Math.trunc(Number(newComment.parent)); // we only want an integer so trunc
if (isNaN(newComment.parent)) { // makes sure the parent id provided is actually a number
res.status(422);
res.json({
error: 'invalid-type-of-parent',
message: 'The parent property should be a Number or string that can be converted to a Number'
});
return; // abort processing of this entity
}
newComment.id = helpers.getNewId(fileJsonData.comments);
newComment.time = DateTime.now().toUTC();
newComment.lastEdited = null; // lastEdited property initially set to null to be updated later
// adds comment id to parent card's comments property
const parentCard = helpers.searchId(fileJsonData.cards, [newComment.parent])[0];
if (!parentCard) {
res.status(404);
res.json({
error: 'parent-card-not-found',
message: 'The parent card with specified id was not found in the database.'
});
} else {
parentCard.comments.push(newComment.id);
fileJsonData.comments.push(newComment);
const jsonString = JSON.stringify(fileJsonData, null, 2);
fs.writeFile('./serverdb.json', jsonString, 'utf-8', (err) => {
if (err) {
console.log(err);
res.status(500);
res.json({
error: 'database-write-error',
message: FILE_ERROR_MESSAGE
});
} else {
res.status(201);
res.json({
message: 'Added new comment successfully.',
newTotalComments: parentCard.comments.length, // newTotalComments is used client side to update page labels
id: newComment.id
});
}
});
}
}
});
})
.put((req, res) => { // e.g. PUT /cards/1; /cards/2
// used to update comment content after initial creation
fs.readFile('./serverdb.json', 'utf8', async (err, fileData) => {
if (err) {
console.log(err);
res.status(500);
res.json({
error: 'database-read-error',
message: FILE_ERROR_MESSAGE
});
} else {
const fileJsonData = JSON.parse(fileData);
const reqParamId = req.params.id;
if (!reqParamId) {
res.status(400); // 400 Bad Request
res.json({
error: 'no-comment-to-put',
message: 'No comment id was provided.'
});
} else {
const newContent = req.body.content;
if (!helpers.requestBodyIsValid(req.body, ['content'], res)) {
return;
}
// find relevant comment object that needs to be updated
const targetComment = helpers.handleIdUrl(fileJsonData.comments, reqParamId, '')[0];
if (!targetComment) { // if no comment with the given id is found, targetComment will be undefined
res.status(404);
res.json({
error: 'comment(s)-not-found',
message: 'No comment with that id was found in the database to update.'
});
} else {
targetComment.content = newContent; // update comment's content to newly supplied content
targetComment.lastEdited = DateTime.now().toUTC(); // lastEdited property updated to current server time
const jsonString = JSON.stringify(fileJsonData, null, 2);
fs.writeFile('./serverdb.json', jsonString, 'utf-8', (err) => {
if (err) {
console.log(err);
res.status(500);
res.json({
error: 'database-write-error',
message: FILE_ERROR_MESSAGE
});
} else {
res.sendStatus(204); // 204 No Content
}
});
}
}
}
});
});
module.exports = app;