Skip to content

Commit

Permalink
Merge branch 'main' into weston/contactRequest
Browse files Browse the repository at this point in the history
  • Loading branch information
westonz7042 authored Jan 16, 2025
2 parents a0c4c5d + 0408c9d commit cf835be
Show file tree
Hide file tree
Showing 8 changed files with 344 additions and 6 deletions.
21 changes: 17 additions & 4 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import { json } from "body-parser";
import express from "express";
import mongoose from "mongoose";

import { port } from "./config";
import { errorHandler } from "./errors/handler";
import contactRoute from "./routes/contactRequest";
import { mongoUri, port } from "./config";

Check warning on line 7 in backend/src/app.ts

View workflow job for this annotation

GitHub Actions / Backend lint and style check

`./config` import should occur before import of `./errors/handler`
import articleRoutes from "./routes/article";

Check warning on line 8 in backend/src/app.ts

View workflow job for this annotation

GitHub Actions / Backend lint and style check

`./routes/article` import should occur before import of `./routes/contactRequest`

// Initialize Express App
const app = express();

app.use(json());
app.use("/api", contactRoute);
app.use("/api/articles", articleRoutes);
app.use(errorHandler);
app.listen(port, () => {
console.log(`> Listening on port ${port}`);
});
mongoose
.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);

4 changes: 3 additions & 1 deletion backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ dotenv.config();
if (!process.env.PORT) throw InternalError.NO_APP_PORT;
const port = process.env.PORT;

export { port };
if (!process.env.EMAIL) throw InternalError.NO_EMAIL;
if (!process.env.EMAIL_PASSWORD) throw InternalError.NO_EMAIL_PASSWORD;
if (!process.env.MONGO_URI) throw InternalError.NO_MONGO_URI;
const mongoUri = process.env.MONGO_URI;
export { port, mongoUri };
58 changes: 58 additions & 0 deletions backend/src/controllers/article.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { RequestHandler } from "express";
import { validationResult } from "express-validator";
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 {
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 reqBody: ArticleUpdate = req.body as ArticleUpdate;
const article = await ArticleModel.findByIdAndUpdate(id, reqBody, {
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);
}
};
5 changes: 4 additions & 1 deletion backend/src/errors/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import { CustomError } from "./errors";
const NO_APP_PORT = "Could not find app port env variable";
const NO_EMAIL = "Could not find email";
const NO_EMAIL_PASSWORD = "Could not find email password";
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_EMAIL = new InternalError(1, 500, NO_EMAIL);
static NO_EMAIL_PASSWORD = new InternalError(2, 500, NO_EMAIL_PASSWORD);
static NO_APP_PORT = new InternalError(0, 500, NO_APP_PORT);
static NO_MONGO_URI = new InternalError(1, 500, NO_MONGO_URI);
}
22 changes: 22 additions & 0 deletions backend/src/models/article.ts
Original file line number Diff line number Diff line change
@@ -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<typeof articleSchema>;

export default model<ArticleItem>("ArticleItem", articleSchema);
12 changes: 12 additions & 0 deletions backend/src/routes/article.ts
Original file line number Diff line number Diff line change
@@ -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("/all", ArticleController.getAllArticles);
router.post("/create", ArticleValidator.createArticle, ArticleController.createArticle);
router.put("/:id", ArticleValidator.updateArticle, ArticleController.updateArticle);

export default router;
56 changes: 56 additions & 0 deletions backend/src/validators/article.ts
Original file line number Diff line number Diff line change
@@ -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(),
];
172 changes: 172 additions & 0 deletions frontend/src/api/requests.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
): Promise<Response> {
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<void> {
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<string, string> = {}): Promise<Response> {
// 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<string, string> = {},
): Promise<Response> {
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<string, string> = {},
): Promise<Response> {
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<string, string> = {},
): Promise<Response> {
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<string, string> = {}): Promise<Response> {
const response = await fetchRequest("DELETE", `${API_BASE_URL}${url}`, undefined, headers);
await assertOk(response);
return response;
}

export type APIData<T> = { success: true; data: T };
export type APIError = { success: false; error: string };
export type APIResult<T> = APIData<T> | 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)}` };
}

0 comments on commit cf835be

Please sign in to comment.