diff --git a/README.md b/README.md index 870f431..d89a9cb 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,88 @@ -# byte-server +# Byte-server Server for byte -# Description -Server is built with Node.js and Express. Provides endpoints for managing a collection of projects, including creating, reading and updating. Exists both a json file and database backup for storing and quering projects +## Description +Server is built with Node.js and Express. Provides endpoints for managing a collection of projects, including creating, reading and updating. Exists both database and a local JSON file backup for storing and quering projects. +## How to use +Creating, Reading and Updating can be done from the database (default approach) endpoints. Only reading can be done for the local JSON file. +## Endpoints for DB + +### Get projects +`/projects/get` + +Retrieves all projects in the database and supports additional query params for filtering. + +Filters include: +- **team**: TYPE string(s) +- **cohort**: TYPE string(s) +- **name**: TYPE string(s) + +Filters are stackable, meaning you can do something similar to: +`/projects/get?team=John&cohort=1&team=Jane` +This will return all projects that have John **AND** Jane in their team and are in cohort 1. You can stack as many filters as you want but be aware that the more filters you add, the more specific the query will be, meaning it might return no results. + +### Post new projects +`/projects/add` + +**Please Fill Out All Fields!!!** +These fields must be provided as JSON in the body of the request. + +The schema for projects is defined as follows: +- **name**: TYPE string(s) +- **"short-desc"**: TYPE string(s) +- **"long-desc"**: TYPE string(s) +- **team**: TYPE array of strings +- **link**: TYPE string(s) +- **Image**: TYPE string(s) +- **"tech-stack"**: TYPE array of strings +- **cohort**: TYPE string(s) +- **topic**: TYPE array of strings + +### UPDATE projects +`/projects/update` + +You **MUST** provide the project name you want to update as a query parameter like so: +`/projects/update?name={project_name}` + +You can provide any key value pair you want to update in the body of the request, but it **HAS** to be JSON. This doesn't have to be all the fields, only the ones you want to update. + +Reminder that the schema for projects is defined as follows: +- **name**: TYPE string(s) +- **"short-desc"**: TYPE string(s) +- **"long-desc"**: TYPE string(s) +- **team**: TYPE array of strings +- **link**: TYPE string(s) +- **Image**: TYPE string(s) +- **"tech-stack"**: TYPE array of strings +- **cohort**: TYPE string(s) +- **topic**: TYPE array of strings + +Any mismatch of the schema types (i.e. providing a string when we expect an array) will return an error. + +## Endpoints for Local File + +### Get all projects +`/projects` + +Retrieves all projects from the local JSON file and also supports additional query params for filtering. + +### Get projects based on team members +`/projects?team={member_name}` + +Replace `{member_name}` with a member that is in the project you would like to query. This filter is stackable, meaning you can do something similar to: +`/projects?team=John&team=Jane` + +### Get projects based on cohort +`/projects?cohort={cohort}` + +Replace `{cohort}` with the desired cohort to filter projects. This filter is stackable, meaning you can do something similar to: +`/projects?cohort=1&cohort=2` + +### Get projects based on name +`/projects?name={project_name}` + +Replace `{project_name}` with the desired project name to filter projects. +This filter is stackable, meaning you can do something similar to: +`/projects?name=Website&name=Server` \ No newline at end of file diff --git a/data.json b/data.json index f8769cc..f904bec 100644 --- a/data.json +++ b/data.json @@ -8,7 +8,11 @@ "image": "https://d1tzawfcgeew72.cloudfront.net/2048AI.png", "tech-stack": ["Tkinter", "PyTorch", "NumPy"], "cohort": "Fall 2025", - "topic": ["AI", "ML"] + "topic": [ + "AI", + "ML" + ], + "uuid": "80eb8a48-9172-4f6f-87ca-4ae89de1938c" }, { "name": "Stockbros", @@ -19,7 +23,11 @@ "image": "https://d1tzawfcgeew72.cloudfront.net/old_byte.png", "tech-stack": ["Pandas", "Plotly", "Flask", "React"], "cohort": "Spring 2024", - "topic": ["Data Science", "Finance"] + "topic": [ + "Data Science", + "Finance" + ], + "uuid": "5dca7d6e-5131-434f-9eb9-5670a820a260" }, { "name": "TrackLeet", @@ -35,7 +43,8 @@ "Chrome Extension", "Web Development", "Authentication" - ] + ], + "uuid": "e00bfd77-58a4-4d2f-a56e-6fc5b8280454" }, { "name": "Don't Be Alarmed", @@ -51,7 +60,10 @@ "Android Studio" ], "cohort": "Spring 2024", - "topic": ["Android App Development"] + "topic": [ + "Android App Development" + ], + "uuid": "9fe8f025-d0a4-4a88-abb5-184da85db827" }, { "name": "BYTE Website", @@ -68,7 +80,10 @@ "Github Actions" ], "cohort": "Summer 2024", - "topic": ["Web Development"] + "topic": [ + "Web Development" + ], + "uuid": "d78df4b2-4ad6-4c0e-82b1-31473c4ef9f2" }, { "name": "BYTE Server", @@ -93,6 +108,7 @@ "Server Development", "API", "Postgres" - ] + ], + "uuid": "52a901bf-685a-4320-9336-5ce7785e4f18" } ] diff --git a/db.config.ts b/db.config.ts index 96c7940..28e6f15 100644 --- a/db.config.ts +++ b/db.config.ts @@ -1,5 +1,5 @@ -import dotenv from 'dotenv'; -import {Client} from 'pg'; +import dotenv from "dotenv"; +import { Client } from "pg"; dotenv.config(); const client = new Client({ @@ -7,7 +7,14 @@ const client = new Client({ user: process.env.POSTGRESQL_DB_USER, password: process.env.POSTGRESQL_DB_PASSWORD, database: process.env.POSTGRESQL_DB, - port: process.env.POSTGRESQL_DB_PORT ? parseInt(process.env.POSTGRESQL_DB_PORT) : 5432 + port: process.env.POSTGRESQL_DB_PORT + ? parseInt(process.env.POSTGRESQL_DB_PORT) + : 5432, }); +client.on("end", () => console.log("Client has disconnected")); +client.on("error", (err) => + console.error("Unexpected error on idle client", err), +); + export default client; diff --git a/db.ts b/db.ts index b58010e..124a989 100644 --- a/db.ts +++ b/db.ts @@ -1,16 +1,20 @@ -import client from './db.config'; +import client from './db.config'; -const activateDb = async () => { - console.log("Connecting to Database ..."); +let isActive = false; +const getDB = async () => { + console.log("Connecting to Database ..."); + if (isActive) { + console.log("Database already connected"); + return client; + } + try { + await client.connect(); + console.log("Database connected"); + isActive = true; + return client; + } catch (err: any) { + return client; + } +} - try { - await client.connect(); - console.log("Database connected"); - - return client; - } catch (err: any) { - throw new Error(`Database connection error\n ${err.message}`); - } -} - -export default activateDb; \ No newline at end of file +export default getDB; \ No newline at end of file diff --git a/dbCheck.ts b/dbCheck.ts new file mode 100644 index 0000000..62eb457 --- /dev/null +++ b/dbCheck.ts @@ -0,0 +1,22 @@ +import client from "./db.config"; + +async function getDB() { + await client.connect(); +} + +async function checkDB(timeout: number) { + setTimeout(async () => { + try { + await getDB(); + process.exit(0); + } catch (e: any) { + console.error("Error Connecting to DB:", e.message); + process.exit(1); + } + }, timeout); +} + +const args: string[] = process.argv; +const timeout: number = parseInt(args[2]); + +await checkDB(timeout); diff --git a/dbChecker.ts b/dbChecker.ts new file mode 100644 index 0000000..99868d0 --- /dev/null +++ b/dbChecker.ts @@ -0,0 +1,34 @@ +import { spawn } from "child_process"; + +export function secondsToMs(d: number) { + return d * 1000; +} + +// threading should happen at top level of server "setInterval" +async function checkDB(TIMEOUT: number): Promise { + return new Promise((resolve, reject) => { + let dbAval: boolean = false; + + const database = spawn("bun", ["dbCheck.ts", TIMEOUT.toString()]); + + database.stdout.on("data", (data) => { + console.log("Output from dbCheck.ts:", data.toString()); + }); + + database.on("exit", (code) => { + if (code === 0) { + dbAval = true; + } else { + dbAval = false; + } + resolve(dbAval); + }); + + database.on("error", (error) => { + console.error(error); + reject(error); + }); + }); +} + +export default checkDB; diff --git a/middlewares/connectDB.ts b/middlewares/connectDB.ts new file mode 100644 index 0000000..f1f8b98 --- /dev/null +++ b/middlewares/connectDB.ts @@ -0,0 +1,14 @@ +import getDB from "../db"; +import logger from "../utils/logger"; + +const connectDB = async (req: any, res: any, next: any) => { + try { + req.client = await getDB(); + next(); + } catch (err: any) { + logger.info(err.message); + next("route"); + } +} + +export default connectDB; diff --git a/middlewares/validate.ts b/middlewares/validate.ts new file mode 100644 index 0000000..646e52a --- /dev/null +++ b/middlewares/validate.ts @@ -0,0 +1,81 @@ +//validate all fields and their types +function validating( + keys: string[], + values: any, + requiredFields: { [key: string]: string }, + res: any, +) { + for (let i = 0; i < values.length; i++) { + //initial check if the field is a required field + if (!(keys[i] in requiredFields)) { + return res + .status(400) + .json({ + message: `Please insert a valid field name, ${keys[i]} is invalid`, + }); + } + //check for the correct typing + if (typeof values[i] !== requiredFields[keys[i]]) { + return res + .status(400) + .json({ + message: `Please insert the correct typing for ${keys[i]}, it should be a ${requiredFields[keys[i]] === "object" ? "array of strings" : requiredFields[keys[i]]}!`, + }); + } + } + // if no response is sent meaning all validations passed, return false at the end of the function + return false; +} + +const validate = (req: any, res: any, next: any) => { + const requiredFields = { + name: "string", + "short-desc": "string", + "long-desc": "string", + team: "object", + link: "string", + image: "string", + "tech-stack": "object", + cohort: "string", + topic: "object", + }; + const values = Object.values(req.body); + const keys = Object.keys(req.body); + + if (req.method === "POST") { + //initial check for empty request body + if (Object.keys(req.body).length === 0) { + return res + .status(400) + .json({ + message: "Please insert a object with all required fields!", + }); + } + if (keys.length !== 9) { + return res + .status(400) + .json({ + message: + "Please insert all required fields, you are missing some fields!", + }); + } else { + if (validating(keys, values, requiredFields, res)) { + return; + } + } + } else { + //initial check for empty request body + if (Object.keys(req.body).length === 0) { + return res + .status(400) + .json({ message: "Please insert a object to update!" }); + } else { + if (validating(keys, values, requiredFields, res)) { + return; + } + } + } + next(); +}; + +export default validate; diff --git a/package.json b/package.json index 6927b1e..5b851c1 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dependencies": { "@types/chai": "^4.3.16", "@types/chai-http": "^4.2.0", + "@types/cors": "^2.8.17", "@types/dotenv": "^8.2.0", "@types/express": "^4.17.21", "@types/mocha": "^10.0.6", diff --git a/routes/databaseFunctions.ts b/routes/databaseFunctions.ts new file mode 100644 index 0000000..3863ea8 --- /dev/null +++ b/routes/databaseFunctions.ts @@ -0,0 +1,17 @@ +import { Client } from "pg"; + +/** + * Function to add all items from values to database + * Assumes values array correctly maps to the database schema (no empty values, etc.) + */ +export function queryDatabase( + client: Client, + query: string, + values: Array, +) { + try { + return client.query(query, values); + } catch (e: any) { + throw Error(e); + } +} diff --git a/routes/projectsDB.ts b/routes/projectsDB.ts new file mode 100644 index 0000000..adf48aa --- /dev/null +++ b/routes/projectsDB.ts @@ -0,0 +1,131 @@ +import { Router } from "express"; +import express from "express"; +import logger from "../utils/logger"; +import { queryDatabase } from "./databaseFunctions"; +import { Client, QueryResult } from "pg"; +import validate from "../middlewares/validate"; +import getDB from "../db"; + +const router: Router = Router(); + +router.use(express.json()); + +async function startServer() { + const client: Client = await getDB(); + + router.use((req: any, res: any, next: any) => { + logger.info(`Received ${req.url} request for database projects`); + next(); + }); + + router.get("/", async (req: any, res: any) => { + try { + return res.status(200).json({ message: "API is operational." }); + } catch (err: any) { + return res.status(500).json({ message: err.message }); + } + }); + + router.get("/get", async (req: any, res: any) => { + let baseQuery = "SELECT * FROM projects"; + const filters: string[] = []; + const values: (string | number)[] = []; + + // if the name filter was provided + if (req.query.name) { + filters.push(`name ILIKE $${filters.length + 1}`); + values.push(`%${req.query.name}%`); + } + + // if the cohort filter was provided + if (req.query.cohort) { + filters.push(`cohort ILIKE $${filters.length + 1}`); + values.push(`%${req.query.cohort}%`); + } + // if the team filter was provided + if (req.query.team) { + filters.push(`team ILIKE $${filters.length + 1}`); + values.push(`%${req.query.team}%`); + } + + // combine all the filters into a single query + if (filters.length > 0) { + baseQuery += " WHERE " + filters.join(" AND "); + } + + // execute the query, making sure to provide the values for the filters + try { + const data: QueryResult = await queryDatabase( + client, + baseQuery, + values, + ); + return res.status(200).send(data.rows); + } catch { + return res.status(500).json({ message: "Error retrieving data" }); + } + }); + + router.post("/add", validate, (req: any, res: any) => { + const values: Array = Object.values(req.body); + const query = ` + INSERT INTO projects (name, "short-desc", "long-desc", team, link, image, "tech-stack", cohort, topic) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`; + try { + queryDatabase(client, query, values); + return res.status(200).json({ message: "Project added successfully" }); + } catch (err: any) { + return res.status(400).json({ message: err.message }); + } + }); + + router.put("/update", validate, async (req: any, res: any) => { + const projectName = req.query.name; + + if (!projectName) { + return res.status(400).json({ message: "Project name is required" }); + } + + const fields = req.body; + if (!fields || Object.keys(fields).length === 0) { + return res + .status(400) + .json({ message: "No fields to update provided" }); + } + + const setClauses: string[] = []; + const values: (string | number)[] = []; + + // Construct the set clauses and values array + Object.keys(fields).forEach((key, index) => { + setClauses.push(`"${key}" = $${index + 1}`); + values.push(fields[key]); + }); + + // Add the project name to the values array for the WHERE clause + values.push(projectName); + + const query = ` + UPDATE projects + SET ${setClauses.join(", ")} + WHERE name = $${values.length}`; + try { + const result = await queryDatabase(client, query, values); + + if (result.rowCount === 0) { + return res.status(404).json({ message: "Project not found" }); + } + + return res.status(200).send("Project updated successfully"); + } catch (err: any) { + return res.status(400).json({ message: err.message }); + } + }); +} + +startServer().catch((err) => { + logger.error(err); + console.log("Failed to start server"); +}); + +export default router; diff --git a/routes/projectsLocal.ts b/routes/projectsLocal.ts new file mode 100644 index 0000000..3e1a15c --- /dev/null +++ b/routes/projectsLocal.ts @@ -0,0 +1,121 @@ +import logger from "../utils/logger"; +import { Router } from "express"; +import { readFile } from "fs"; +import path from "path"; + +const router = Router(); +const FILE_PATH: string = path.resolve(__dirname, "../data.json"); + +router.use((req: any, res: any, next: any) => { + logger.info(`Received ${req.url} request for local projects`); + next(); +}); + +router.get("/", (req: any, res: any) => { + if (req.query) { + logger.warn("Query parameters ignored"); + } + + readFile(FILE_PATH, "utf8", (error: any, content: any) => { + if (error) { + logger.error("Error reading data.json"); + return res.status(500).send("Error reading file"); + } + return res.status(200).json(JSON.parse(content)); + }); +}); + +router.get("/team", (req: any, res: any) => { + if (!req.query.team) { + logger.error("Team query parameter missing"); + res.status(400).send("Missing team"); + return; + } + + readFile(FILE_PATH, "utf8", (error: any, content: any) => { + if (error) { + logger.error("Error reading data.json"); + return res.status(500).send("Error reading file"); + } + const jsonData = JSON.parse(content); + const filteredData = jsonData.filter((item: any) => { + const itemData = item.team.toString().toLowerCase().split(","); + const queryData = req.query.team + ?.toString() + .toLowerCase() + .split(","); + return queryData?.every((query: any) => itemData.includes(query)); + }); + if (filteredData.length === 0) { + logger.warn("No projects found"); + return res + .status(404) + .send("The data you are looking for does not exist"); + } + return res.status(200).send(filteredData); + }); +}); + +router.get("/cohort", (req: any, res: any) => { + if (!req.query.cohort) { + logger.error("Cohort query parameter missing"); + res.send("Missing cohort").status(400); + return; + } + + readFile(FILE_PATH, "utf8", (err: any, data: any) => { + if (err) { + logger.error("Error reading data.json"); + res.send("Error reading file").status(500); + } + const jsonData = JSON.parse(data); + const filteredData = jsonData.filter((item: any) => { + const itemData = item.cohort.toString().toLowerCase().split(","); + const queryData = req.query.cohort + ?.toString() + .toLowerCase() + .split(","); + console.log(itemData, queryData); + return itemData.some((item: any) => queryData?.includes(item)); + }); + + if (filteredData.length === 0) { + logger.warn("No projects found"); + res.send("No projects found").status(404); + return; + } + res.send(filteredData).status(200); + }); +}); + +router.get("/name", (req: any, res: any) => { + if (!req.query.name) { + logger.error("Name query parameter missing"); + res.send("Missing project name").status(400); + return; + } + + readFile(FILE_PATH, "utf8", (err: any, data: any) => { + if (err) { + logger.error("Error reading data.json"); + res.send("Error reading file").status(500); + } + const jsonData = JSON.parse(data); + const filteredData = jsonData.filter((item: any) => { + const itemData = item.name.toString().toLowerCase(); + const queryData = req.query.name + ?.toString() + .toLowerCase() + .split(","); + return queryData?.some((query: any) => itemData.includes(query)); + }); + if (filteredData.length === 0) { + logger.warn("No projects found"); + res.send("No projects found").status(404); + return; + } + res.send(filteredData).status(200); + }); +}); + +export default router; diff --git a/server.ts b/server.ts index 92b6615..42cf0b9 100644 --- a/server.ts +++ b/server.ts @@ -1,142 +1,66 @@ +import logger from "./utils/logger"; +import projectsLocal from "./routes/projectsLocal"; +import projectsDB from "./routes/projectsDB"; import express from "express"; -import activateDb from "./db"; -import { readFile } from "fs"; -import winston from "winston"; +import checkDB, { secondsToMs } from "./dbChecker"; import cors from "cors"; +import http from 'http'; +import https from 'https'; -// activateDb(); -const app = express(); -const PORT = 3000; +// const privateKey = fs.readFileSync('sslcert/server.key', 'utf8'); +// const certificate = fs.readFileSync('sslcert/server.crt', 'utf8'); +// const credentials = {key: privateKey, cert: certificate}; -const logger = winston.createLogger({ - // Log only if level is less than (meaning more severe) or equal to this - level: "info", - // Use timestamp and printf to create a standard log format - format: winston.format.combine( - winston.format.timestamp(), - winston.format.printf( - (info) => `${info.timestamp} ${info.level}: ${info.message}`, - ), - ), - // Log to the console and a file - transports: [ - new winston.transports.Console(), - new winston.transports.File({ filename: "logs/app.log" }), - ], -}); +const PORT = 3000; +const INTERVAL = secondsToMs(60 * 60); // 1 hr +const TIMEOUT = secondsToMs(10); +const app = express(); +let dbAval: boolean = true; + +setInterval(async () => { + try { + dbAval = await checkDB(TIMEOUT); + } catch (e: any) { + console.error("Error:", e.message); + dbAval = false; + } + logger.info(`Database is ${dbAval ? "available" : "not available"}`); +}, INTERVAL); app.use(cors()); - -app.use((req, res, next) => { - // Log an info message for each incoming request - logger.info(`Received a ${req.method} request for ${req.url}`); - next(); -}); - -app.get("/", (req, res) => { - res.send("BYTE @ CCNY").status(200); +app.use((req: any, res: any, next: any) => { + logger.info(`Received a ${req.method} request for ${req.url}`); + next(); }); -app.get("/projects", (req, res) => { - if (req.query) { - logger.warn("Query parameters ignored"); - } - - readFile("./data.json", "utf8", (error, content) => { - if (error) { - logger.error("Error reading data.json"); - return res.status(500).send("Error reading file"); +app.use("/projects", (req: any, res: any, next: any) => { + if (dbAval) { + projectsDB(req, res, next); + } else { + projectsLocal(req, res, next); } - return res.status(200).json(JSON.parse(content)); - }); }); -app.get("/projects/team", (req, res) => { - if (!req.query.team) { - logger.error("Team query parameter missing"); - res.status(400).send("Missing team"); - return; - } - - readFile("./data.json", "utf8", (error, content) => { - if (error) { - logger.error("Error reading data.json"); - return res.status(500).send("Error reading file"); - } - const jsonData = JSON.parse(content); - const filteredData = jsonData.filter((item: any) => { - const itemData = item.team.toString().toLowerCase().split(","); - const queryData = req.query.team?.toString().toLowerCase().split(","); - return queryData?.every((query) => itemData.includes(query)); - }); - if (filteredData.length === 0) { - logger.warn("No projects found"); - return res - .status(404) - .send("The data you are looking for does not exist"); - } - return res.status(200).send(filteredData); - }); +app.get("/", (req: any, res: any) => { + res.send( + `BYTE @ CCNY. The database is ${dbAval ? "available" : "not available"}`, + ).status(200); }); -app.get("/projects/cohort", (req, res) => { - if (!req.query.cohort) { - logger.error("Cohort query parameter missing"); - res.send("Missing cohort").status(400); - return; - } - - readFile("data.json", "utf8", (err, data) => { - if (err) { - logger.error("Error reading data.json"); - res.send("Error reading file").status(500); - } - const jsonData = JSON.parse(data); - const filteredData = jsonData.filter((item: any) => { - const itemData = item.cohort.toString().toLowerCase().split(","); - const queryData = req.query.cohort?.toString().toLowerCase().split(","); - console.log(itemData, queryData); - return itemData.some((item: any) => queryData?.includes(item)); +// any other route will return a 404 +app.get("*", (req: any, res: any) => { + res.status(404).json({ + message: + "Page not found. Invalid path or method provided to make this request.", }); - - if (filteredData.length === 0) { - logger.warn("No projects found"); - res.send("No projects found").status(404); - return; - } - res.send(filteredData).status(200); - }); }); -app.get("/projects/name", (req, res) => { - if (!req.query.name) { - logger.error("Name query parameter missing"); - res.send("Missing project name").status(400); - return; - } +// app.listen(PORT, () => { +// console.log(`listening on port ${PORT}`); +// }); - readFile("data.json", "utf8", (err, data) => { - if (err) { - logger.error("Error reading data.json"); - res.send("Error reading file").status(500); - } - const jsonData = JSON.parse(data); - const filteredData = jsonData.filter((item: any) => { - const itemData = item.name.toString().toLowerCase(); - const queryData = req.query.name?.toString().toLowerCase().split(","); - return queryData?.some((query) => itemData.includes(query)); - }); - if (filteredData.length === 0) { - logger.warn("No projects found"); - res.send("No projects found").status(404); - return; - } - res.send(filteredData).status(200); - }); -}); - -app.listen(PORT, () => { - console.log(`listening on port ${PORT}`); -}); +const httpServer = http.createServer(app); +// const httpsServer = https.createServer(credentials, app); -export default app; +httpServer.listen(PORT); +// httpsServer.listen(PORT+1); \ No newline at end of file diff --git a/utils/logger.ts b/utils/logger.ts new file mode 100644 index 0000000..dd2f5ac --- /dev/null +++ b/utils/logger.ts @@ -0,0 +1,20 @@ +import winston from "winston"; + +const logger = winston.createLogger({ + // Log only if level is less than (meaning more severe) or equal to this + level: "info", + // Use timestamp and printf to create a standard log format + format: winston.format.combine( + winston.format.timestamp(), + winston.format.printf( + (info) => `${info.timestamp} ${info.level}: ${info.message}` + ) + ), + // Log to the console and a file + transports: [ + new winston.transports.Console(), + new winston.transports.File({ filename: "logs/app.log" }), + ], +}); + +export default logger; \ No newline at end of file