From 9a6c00e5c6b7c7867ea95afceac2de4a8a64eaed Mon Sep 17 00:00:00 2001 From: EdwardLinS Date: Sun, 12 Jan 2025 14:25:32 -0800 Subject: [PATCH 01/10] Create article schema --- backend/src/models/article.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 backend/src/models/article.ts diff --git a/backend/src/models/article.ts b/backend/src/models/article.ts new file mode 100644 index 0000000..d94c8bf --- /dev/null +++ b/backend/src/models/article.ts @@ -0,0 +1,22 @@ +import { InferSchemaType, Schema, model } from "mongoose"; + +const articleSchema = new Schema({ + // Header/title of article + header: { type: String, required: true }, + + // Creation date of article + dateCreated: { type: Date, required: true }, + + // Author of article + author: { type: String, required: true }, + + // Text content of article + body: { type: String }, + + // File URL to article image + thumbnail: { type: String }, +}); + +type ArticleItem = InferSchemaType; + +export default model("ArticleItem", articleSchema); From 9330127da3918662c2e4efbc2491b6a29a0593fb Mon Sep 17 00:00:00 2001 From: EdwardLinS Date: Sun, 12 Jan 2025 18:32:32 -0800 Subject: [PATCH 02/10] Create article controllers --- backend/package-lock.json | 29 ++++++++++++++ backend/package.json | 1 + backend/src/controllers/article.ts | 49 +++++++++++++++++++++++ backend/src/util/validationErrorParser.ts | 27 +++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 backend/src/controllers/article.ts create mode 100644 backend/src/util/validationErrorParser.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 494eac6..ed34659 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "dotenv": "^16.4.7", "express": "^4.21.1", + "express-validator": "^7.2.1", "mongoose": "^8.8.3" }, "devDependencies": { @@ -1997,6 +1998,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-validator": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", + "integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.12.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3083,6 +3097,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4732,6 +4752,15 @@ "dev": true, "license": "MIT" }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index 0b200ba..e33a7a8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,6 +17,7 @@ "dependencies": { "dotenv": "^16.4.7", "express": "^4.21.1", + "express-validator": "^7.2.1", "mongoose": "^8.8.3" }, "devDependencies": { diff --git a/backend/src/controllers/article.ts b/backend/src/controllers/article.ts new file mode 100644 index 0000000..1ea4151 --- /dev/null +++ b/backend/src/controllers/article.ts @@ -0,0 +1,49 @@ +import { RequestHandler } from "express"; +import { validationResult } from "express-validator"; +import createHttpError from "http-errors"; + +import ArticleModel from "../models/article"; +import validationErrorParser from "../util/validationErrorParser"; + +export const createArticle: RequestHandler = async (req, res, next) => { + const errors = validationResult(req); + try { + validationErrorParser(errors); + const article = await ArticleModel.create(req.body); + res.status(201).json(article); + } catch (error) { + next(error); + } +}; + +export const getAllArticles: RequestHandler = async (req, res, next) => { + try { + const articles = await ArticleModel.find(); + res.status(200).json(articles); + } catch (error) { + next(error); + } +}; + +export const updateArticle: RequestHandler = async (req, res, next) => { + const errors = validationResult(req); + try { + const { id } = req.params; + validationErrorParser(errors); + + // Returns the updated article + const article = await ArticleModel.findByIdAndUpdate(id, req.body, { + new: true, + runValidators: true, + }); + + // Check if article of id exists + if (article === null) { + throw createHttpError(404, "Article not found at id " + id); + } + + res.status(200).json(article); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/util/validationErrorParser.ts b/backend/src/util/validationErrorParser.ts new file mode 100644 index 0000000..a49f73c --- /dev/null +++ b/backend/src/util/validationErrorParser.ts @@ -0,0 +1,27 @@ +import { Result, ValidationError } from "express-validator"; +import createHttpError from "http-errors"; + +/** + * Parses through errors thrown by validator (if any exist). Error messages are + * added to a string and that string is used as the error message for the HTTP + * error. + * + * From the TSE onboarding repo (https://github.com/TritonSE/onboarding) + * + * @param errors the validation result provided by express validator middleware + */ +const validationErrorParser = (errors: Result) => { + if (!errors.isEmpty()) { + let errorString = ""; + + // parse through errors returned by the validator and append them to the error string + for (const error of errors.array()) { + errorString += error.msg + " "; + } + + // trim removes the trailing space created in the for loop + throw createHttpError(400, errorString.trim()); + } +}; + +export default validationErrorParser; From d07ca842f4a595de9ead03ba28a528a35e6b78d7 Mon Sep 17 00:00:00 2001 From: EdwardLinS Date: Sun, 12 Jan 2025 18:36:03 -0800 Subject: [PATCH 03/10] Create article route validators --- backend/src/validators/article.ts | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 backend/src/validators/article.ts diff --git a/backend/src/validators/article.ts b/backend/src/validators/article.ts new file mode 100644 index 0000000..aa3e152 --- /dev/null +++ b/backend/src/validators/article.ts @@ -0,0 +1,56 @@ +import { body } from "express-validator"; + +/** + * Validators for creating and updating articles + */ +const makeHeaderValidator = () => + body("header") + .exists() + .withMessage("header is required") + .bail() + .isString() + .withMessage("header must be a string") + .bail() + .notEmpty() + .withMessage("header cannot be empty"); + +const makeDateCreatedValidator = () => + body("dateCreated") + .exists() + .withMessage("dateCreated is required") + .bail() + .isISO8601() + .withMessage("dateCreated must be a valid date-time string"); + +const makeAuthorValidator = () => + body("author") + .exists() + .withMessage("author is required") + .bail() + .isString() + .withMessage("author must be a string") + .bail() + .notEmpty() + .withMessage("author cannot be empty"); + +const makeBodyValidator = () => + body("body").optional().isString().withMessage("body must be a string"); + +const makeThumbnailValidator = () => + body("thumbnail").optional().isURL().withMessage("thumbnail must be a url"); + +export const createArticle = [ + makeHeaderValidator(), + makeDateCreatedValidator(), + makeAuthorValidator(), + makeBodyValidator(), + makeThumbnailValidator(), +]; + +export const updateArticle = [ + makeHeaderValidator(), + makeDateCreatedValidator(), + makeAuthorValidator(), + makeBodyValidator(), + makeThumbnailValidator(), +]; From 45a2494c39333d39b64fb91ba3a5d440f631ccba Mon Sep 17 00:00:00 2001 From: EdwardLinS Date: Sun, 12 Jan 2025 18:41:10 -0800 Subject: [PATCH 04/10] Create article routes for getting all, creating, and updating articles --- backend/src/app.ts | 21 ++++++++++++++++----- backend/src/config.ts | 5 ++++- backend/src/errors/internal.ts | 2 ++ backend/src/routes/article.ts | 12 ++++++++++++ 4 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 backend/src/routes/article.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index a527ff9..86db0f9 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,7 +1,9 @@ import { json } from "body-parser"; import express from "express"; +import mongoose from "mongoose"; -import { port } from "./config"; +import { mongoUri, port } from "./config"; +import articleRoutes from "./routes/article"; // Initialize Express App const app = express(); @@ -9,7 +11,16 @@ const app = express(); // Provide json body-parser middleware app.use(json()); -// Tell app to listen on our port environment variable -app.listen(port, () => { - console.log(`> Listening on port ${port}`); -}); +app.use("/api/article", articleRoutes); + +mongoose + .connect("mongodb://localhost:27017") + // .connect(mongoUri) + .then(() => { + console.log("Mongoose connected!"); + // Tell app to listen on our port environment variable + app.listen(port, () => { + console.log(`> Listening on port ${port}`); + }); + }) + .catch(console.error); diff --git a/backend/src/config.ts b/backend/src/config.ts index b0d0ebe..b6a19f4 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -8,4 +8,7 @@ dotenv.config(); if (!process.env.PORT) throw InternalError.NO_APP_PORT; const port = process.env.PORT; -export { port }; +if (!process.env.MONGO_URI) throw InternalError.NO_MONGO_URI; +const mongoUri = process.env.MONGO_URI; + +export { port, mongoUri }; diff --git a/backend/src/errors/internal.ts b/backend/src/errors/internal.ts index 67e78cd..07d56ad 100644 --- a/backend/src/errors/internal.ts +++ b/backend/src/errors/internal.ts @@ -1,7 +1,9 @@ import { CustomError } from "./errors"; const NO_APP_PORT = "Could not find app port env variable"; +const NO_MONGO_URI = "Could not find mongo uri env variable"; export class InternalError extends CustomError { static NO_APP_PORT = new InternalError(0, 500, NO_APP_PORT); + static NO_MONGO_URI = new InternalError(1, 500, NO_MONGO_URI); } diff --git a/backend/src/routes/article.ts b/backend/src/routes/article.ts new file mode 100644 index 0000000..ba5ad13 --- /dev/null +++ b/backend/src/routes/article.ts @@ -0,0 +1,12 @@ +import express from "express"; + +import * as ArticleController from "../controllers/article"; +import * as ArticleValidator from "../validators/article"; + +const router = express.Router(); + +router.get("/", ArticleController.getAllArticles); +router.post("/", ArticleValidator.createArticle, ArticleController.createArticle); +router.put("/:id", ArticleValidator.updateArticle, ArticleController.updateArticle); + +export default router; From d4f0c8c1c0d43fa45de406e11a2b190d9c4c8b35 Mon Sep 17 00:00:00 2001 From: EdwardLinS Date: Sun, 12 Jan 2025 18:57:45 -0800 Subject: [PATCH 05/10] Fix linter errors --- backend/src/app.ts | 3 ++- backend/src/controllers/article.ts | 11 ++++++++++- backend/src/util/validationErrorParser.ts | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 86db0f9..4f3e0ed 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2,7 +2,8 @@ import { json } from "body-parser"; import express from "express"; import mongoose from "mongoose"; -import { mongoUri, port } from "./config"; +// import { mongoUri, port } from "./config"; +import { port } from "./config"; import articleRoutes from "./routes/article"; // Initialize Express App diff --git a/backend/src/controllers/article.ts b/backend/src/controllers/article.ts index 1ea4151..90524a5 100644 --- a/backend/src/controllers/article.ts +++ b/backend/src/controllers/article.ts @@ -5,6 +5,14 @@ import createHttpError from "http-errors"; import ArticleModel from "../models/article"; import validationErrorParser from "../util/validationErrorParser"; +type ArticleUpdate = { + header: string; + dateCreated: Date; + author: string; + body?: string | null; + thumbnail?: string | null; +}; + export const createArticle: RequestHandler = async (req, res, next) => { const errors = validationResult(req); try { @@ -32,7 +40,8 @@ export const updateArticle: RequestHandler = async (req, res, next) => { validationErrorParser(errors); // Returns the updated article - const article = await ArticleModel.findByIdAndUpdate(id, req.body, { + const reqBody : ArticleUpdate = req.body; + const article = await ArticleModel.findByIdAndUpdate(id, reqBody, { new: true, runValidators: true, }); diff --git a/backend/src/util/validationErrorParser.ts b/backend/src/util/validationErrorParser.ts index a49f73c..6cde3bb 100644 --- a/backend/src/util/validationErrorParser.ts +++ b/backend/src/util/validationErrorParser.ts @@ -16,7 +16,7 @@ const validationErrorParser = (errors: Result) => { // parse through errors returned by the validator and append them to the error string for (const error of errors.array()) { - errorString += error.msg + " "; + errorString += String(error.msg) + " "; } // trim removes the trailing space created in the for loop From 5fe296503a033ed891ada68a336cb1c54f65d198 Mon Sep 17 00:00:00 2001 From: EdwardLinS Date: Tue, 14 Jan 2025 17:08:46 -0800 Subject: [PATCH 06/10] Change POST and GET routes to correct routes --- backend/src/app.ts | 2 +- backend/src/routes/article.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 4f3e0ed..1a85ef0 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -12,7 +12,7 @@ const app = express(); // Provide json body-parser middleware app.use(json()); -app.use("/api/article", articleRoutes); +app.use("/api/articles", articleRoutes); mongoose .connect("mongodb://localhost:27017") diff --git a/backend/src/routes/article.ts b/backend/src/routes/article.ts index ba5ad13..0e3123f 100644 --- a/backend/src/routes/article.ts +++ b/backend/src/routes/article.ts @@ -5,8 +5,8 @@ import * as ArticleValidator from "../validators/article"; const router = express.Router(); -router.get("/", ArticleController.getAllArticles); -router.post("/", ArticleValidator.createArticle, ArticleController.createArticle); +router.get("/all", ArticleController.getAllArticles); +router.post("/create", ArticleValidator.createArticle, ArticleController.createArticle); router.put("/:id", ArticleValidator.updateArticle, ArticleController.updateArticle); export default router; From 72e45eb39cebae86f794487ef330827199494d72 Mon Sep 17 00:00:00 2001 From: EdwardLinS Date: Tue, 14 Jan 2025 17:12:23 -0800 Subject: [PATCH 07/10] Fix article request body type linter error --- backend/src/controllers/article.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/controllers/article.ts b/backend/src/controllers/article.ts index 90524a5..1001093 100644 --- a/backend/src/controllers/article.ts +++ b/backend/src/controllers/article.ts @@ -40,7 +40,7 @@ export const updateArticle: RequestHandler = async (req, res, next) => { validationErrorParser(errors); // Returns the updated article - const reqBody : ArticleUpdate = req.body; + const reqBody : ArticleUpdate = req.body as ArticleUpdate; const article = await ArticleModel.findByIdAndUpdate(id, reqBody, { new: true, runValidators: true, From 40b093f8643ce8e1f2bb848b86bff753990be5e3 Mon Sep 17 00:00:00 2001 From: EdwardLinS Date: Tue, 14 Jan 2025 17:18:29 -0800 Subject: [PATCH 08/10] Format files according to prettier --- backend/src/controllers/article.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/controllers/article.ts b/backend/src/controllers/article.ts index 1001093..ffedd87 100644 --- a/backend/src/controllers/article.ts +++ b/backend/src/controllers/article.ts @@ -40,7 +40,7 @@ export const updateArticle: RequestHandler = async (req, res, next) => { validationErrorParser(errors); // Returns the updated article - const reqBody : ArticleUpdate = req.body as ArticleUpdate; + const reqBody: ArticleUpdate = req.body as ArticleUpdate; const article = await ArticleModel.findByIdAndUpdate(id, reqBody, { new: true, runValidators: true, From 715b50281550d95ea6a914f8763972af0b9cceb3 Mon Sep 17 00:00:00 2001 From: EdwardLinS Date: Tue, 14 Jan 2025 17:20:17 -0800 Subject: [PATCH 09/10] Change to correct mongo uri --- backend/src/app.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 1a85ef0..0ee1fe5 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2,8 +2,7 @@ import { json } from "body-parser"; import express from "express"; import mongoose from "mongoose"; -// import { mongoUri, port } from "./config"; -import { port } from "./config"; +import { mongoUri, port } from "./config"; import articleRoutes from "./routes/article"; // Initialize Express App @@ -15,8 +14,7 @@ app.use(json()); app.use("/api/articles", articleRoutes); mongoose - .connect("mongodb://localhost:27017") - // .connect(mongoUri) + .connect(mongoUri) .then(() => { console.log("Mongoose connected!"); // Tell app to listen on our port environment variable From 154162963063fc9c5697f9cc5d80c785f2a042a4 Mon Sep 17 00:00:00 2001 From: yunshan <156245615+yyunshanli@users.noreply.github.com> Date: Wed, 15 Jan 2025 12:08:16 -0800 Subject: [PATCH 10/10] CRUD Routes --- frontend/src/api/requests.ts | 172 +++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 frontend/src/api/requests.ts diff --git a/frontend/src/api/requests.ts b/frontend/src/api/requests.ts new file mode 100644 index 0000000..1002577 --- /dev/null +++ b/frontend/src/api/requests.ts @@ -0,0 +1,172 @@ +/** + * Based on the TSE Onboarding API client implementation by benjaminJohnson2204: + * https://github.com/TritonSE/onboarding/blob/main/frontend/src/api/requests.ts + */ + +/** + * A custom type defining which HTTP methods we will handle in this file + */ +type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; + +/** + * A wrapper around the built-in `fetch()` function that abstracts away some of + * the low-level details so we can focus on the important parts of each request. + * + * @param method The HTTP method to use + * @param url The URL to request + * @param body The body of the request, or undefined if there is none + * @param headers The headers of the request + * @returns The Response object returned by `fetch() + */ +async function fetchRequest( + method: Method, + url: string, + body: unknown, + headers: Record, +): Promise { + const hasBody = body !== undefined; + + const newHeaders = { ...headers }; + if (hasBody) { + newHeaders["Content-Type"] = "application/json"; + } + + const response = await fetch(url, { + method, + headers: newHeaders, + body: hasBody ? JSON.stringify(body) : undefined, + }); + + return response; +} + +/** + * Throws an error if the given response's status code indicates an error + * occurred, else does nothing. + * + * @param response A response returned by `fetch()` or `fetchRequest()` + * @throws An error if the response was not successful (200-299) or a redirect + * (300-399) + */ +async function assertOk(response: Response): Promise { + if (response.ok) { + return; + } + + let message = `${response.status.toString()} ${response.statusText}`; + + try { + const text = await response.text(); + if (text) { + message += ": " + text; + } + } catch { + // skip errors + } + + throw new Error(message); +} + +/** + * Sends a GET request to the provided API URL. + * + * @param url The URL to request + * @param headers The headers of the request (optional) + * @returns The Response object returned by `fetch()` + */ +export async function get(url: string, headers: Record = {}): Promise { + // GET requests do not have a body + const response = await fetchRequest("GET", `${API_BASE_URL}${url}`, undefined, headers); + await assertOk(response); + return response; +} + +/** + * Sends a POST request to the provided API URL. + * + * @param url The URL to request + * @param body The body of the request, or undefined if there is none + * @param headers The headers of the request (optional) + * @returns The Response object returned by `fetch()` + */ +export async function post( + url: string, + body: unknown, + headers: Record = {}, +): Promise { + const response = await fetchRequest("POST", `${API_BASE_URL}${url}`, body, headers); + await assertOk(response); + return response; +} + +/** + * Sends a PUT request to the provided API URL. + * + * @param url The URL to request + * @param body The body of the request, or undefined if there is none + * @param headers The headers of the request (optional) + * @returns The Response object returned by `fetch()` + */ +export async function put( + url: string, + body: unknown, + headers: Record = {}, +): Promise { + const response = await fetchRequest("PUT", `${API_BASE_URL}${url}`, body, headers); + await assertOk(response); + return response; +} + +/** + * Sends a PATCH request to the provided API URL. + * + * @param url The URL to request + * @param body The body of the request, or undefined if there is none + * @param headers The headers of the request (optional) + * @returns The Response object returned by `fetch()` + */ +export async function patch( + url: string, + body: unknown, + headers: Record = {}, +): Promise { + const response = await fetchRequest("PATCH", `${API_BASE_URL}${url}`, body, headers); + await assertOk(response); + return response; +} + +/** + * Sends a DELETE request to the provided API URL. + * + * @param url The URL to request + * @param headers The headers of the request (optional) + * @returns The Response object returned by `fetch()` + */ +export async function del(url: string, headers: Record = {}): Promise { + const response = await fetchRequest("DELETE", `${API_BASE_URL}${url}`, undefined, headers); + await assertOk(response); + return response; +} + +export type APIData = { success: true; data: T }; +export type APIError = { success: false; error: string }; +export type APIResult = APIData | APIError; + +/** + * Helper function for API client functions to handle errors consistently. + * Recommended usage is in a `catch` block--see `createTask` in `src/api/tasks` + * for an example. + * + * @param error An error thrown by a lower-level API function + * @returns An `APIError` object with a message from the given error + */ +export function handleAPIError(error: unknown): APIError { + if (error instanceof Error) { + return { success: false, error: error.message }; + } else if (typeof error === "string") { + return { success: false, error }; + } + return { success: false, error: `Unknown error: ${String(error)}` }; +}