diff --git a/.env.example b/.env.example deleted file mode 100644 index 6d05b4ad..00000000 --- a/.env.example +++ /dev/null @@ -1 +0,0 @@ -PGURL="postgres://[user]:[password]@[host]/[dbname]" diff --git a/db/index.js b/db/index.js index af723442..2577751a 100644 --- a/db/index.js +++ b/db/index.js @@ -1,25 +1,19 @@ -// Load our .env file -require('dotenv').config() -// Require Client obj from the postgres node module -const { Client } = require("pg"); +const { Pool } = require('pg'); + +const { PGHOST, PGDATABASE, PGUSER, PGPASSWORD } = process.env; + +const dbConnection = new Pool({ + host: PGHOST, + database: PGDATABASE, + username: PGUSER, + password: PGPASSWORD, + port: 5432, + ssl: { + require: true, + }, +}) + +module.exports = dbConnection -const client = { - query: async (str, values) => { - // Get the connection string from process.env - - // the dotenv library sets this variable based - // on the contents of our env file - // Create a new connection to the database using the Client - // object provided by the postgres node module - const dbClient = new Client(process.env.PGURL) - // connect a connection - await dbClient.connect() - // execute the query - const result = await dbClient.query(str, values) - // close the connection - await dbClient.end() - return result - } -} -module.exports = client; diff --git a/package-lock.json b/package-lock.json index 37ccdff8..b886cb18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-async-errors": "^3.1.1", "faker": "^5.5.3", "morgan": "1.10.0", "pg": "8.6.0", @@ -2064,6 +2065,14 @@ "node": ">= 0.10.0" } }, + "node_modules/express-async-errors": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz", + "integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==", + "peerDependencies": { + "express": "^4.16.2" + } + }, "node_modules/express/node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", diff --git a/package.json b/package.json index 57d8b4fb..831dbdf6 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,19 @@ "scripts": { "start": "npx nodemon src/index.js", "test": "npx jest -i test/api/routes --forceExit", - "test-extensions": "npx jest -i test/api/extensions --forceExit" + "test-extensions": "npx jest -i test/api/extensions --forceExit", + "test-books": "npx jest -i test/api/routes/books.spec.js --forceExit", + "test-books-extensions": "npx jest -i test/api/extensions/books.spec.js --forceExit", + "test-pets": "npx jest -i test/api/routes/pets.spec.js --forceExit", + "test-pets-extensions": "npx jest -i test/api/extensions/pets.spec.js --forceExit", + "test-breeds-extensions": "npx jest -i test/api/extensions/breeds.spec.js --forceExit" }, "dependencies": { "body-parser": "^1.20.2", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-async-errors": "^3.1.1", "faker": "^5.5.3", "morgan": "1.10.0", "pg": "8.6.0", diff --git a/src/controllers/booksControllers.js b/src/controllers/booksControllers.js new file mode 100644 index 00000000..34f1088d --- /dev/null +++ b/src/controllers/booksControllers.js @@ -0,0 +1,104 @@ +const { + MissingFieldsError, + NoDataError, + InvalidParameterError, + DataAlreadyExistsError, +} = require("../errors/errors"); +const { + fetchAllBooks, + postBook, + fetchBookById, + updateBookById, + deleteBookById, + fetchBookByQuery, +} = require("../dal/bookRepository"); + +async function getBooksController(req, res) { + let books; + const query = req.query; + + if (query) { + if (query.perPage < 10 || query.perPage > 50) { + throw new InvalidParameterError( + `parameter invalid perPage: ${query.perPage} not valid. Accepted range is 10 - 50` + ); + } + books = await fetchBookByQuery(query); + } else { + books = await fetchAllBooks(); + } + + res + .status(200) + .json({ books, page: Number(query.page), per_page: Number(query.perPage) }); +} + +async function addBookController(req, res) { + const newBook = req.body; + const requiredProperties = [ + "title", + "type", + "author", + "topic", + "publication_date", + "pages", + ]; + const allFieldsExist = requiredProperties.every( + (property) => newBook[property] + ); + if (!allFieldsExist) { + throw new MissingFieldsError( + "Books require a title, type, author, topic, publication year, and number of pages" + ); + } + const book = await postBook(newBook); + res.status(201).json({ book }); +} + +async function getBookByIdController(req, res) { + targetBookId = Number(req.params.id); + + const book = await fetchBookById(targetBookId); + if (!book) { + throw new NoDataError(`no book with id: ${targetBookId}`); + } + res.status(200).json({ book }); +} + +async function putBookByIdController(req, res, next) { + const targetBookId = Number(req.params.id); + const newParams = req.body; + + const allBooks = await fetchAllBooks(); + if (allBooks.find((book) => book.title === newParams.title)) { + throw new DataAlreadyExistsError( + `A book with the title: ${newParams.title} already exists` + ); + } + + const book = await fetchBookById(targetBookId); + if (!book) { + throw new NoDataError(`no book with id: ${targetBookId}`); + } + const updatedBook = await updateBookById(targetBookId, newParams); + res.status(201).json({ book: updatedBook }); +} + +async function deleteBookByIdController(req, res, next) { + const targetBookId = Number(req.params.id); + + const book = await fetchBookById(targetBookId); + if (!book) { + throw new NoDataError(`no book with id: ${targetBookId}`); + } + const deletedBook = await deleteBookById(targetBookId); + res.status(201).json({ book: deletedBook }); +} + +module.exports = { + addBookController, + getBooksController, + getBookByIdController, + putBookByIdController, + deleteBookByIdController, +}; diff --git a/src/controllers/breedsControllers.js b/src/controllers/breedsControllers.js new file mode 100644 index 00000000..12c898b5 --- /dev/null +++ b/src/controllers/breedsControllers.js @@ -0,0 +1,9 @@ +const { fetchBreeds } = require("../dal/breedsRepository"); + +async function getAllBreedsController(req, res) { + const breeds = await fetchBreeds(req.query); + console.log({ breeds }) + res.status(200).json({ breeds }); +} + +module.exports = { getAllBreedsController }; diff --git a/src/controllers/petsControllers.js b/src/controllers/petsControllers.js new file mode 100644 index 00000000..8d4ec709 --- /dev/null +++ b/src/controllers/petsControllers.js @@ -0,0 +1,94 @@ +const { MissingFieldsError, NoDataError, InvalidParameterError } = require("../errors/errors"); + +const { + fetchAllPets, + fetchPetById, + updatePetById, + addPet, + deletePet, + fetchPetsWithQuery, +} = require("../dal/petsRepository"); + +async function getPetsController(req, res) { + let pets; + + if (req.query) { + if (req.query.perPage < 10 || req.query.perPage > 50) { + throw new InvalidParameterError(`parameter invalid perPage: ${req.query.perPage} not valid. Accepted range is 10 - 50`) + } + pets = await fetchPetsWithQuery(req.query); + } else { + pets = await fetchAllPets(); + } + + res + .status(200) + .json({ + pets, + page: Number(req.query.page), + per_page: Number(req.query.perPage), + }); +} + +async function getPetsByIdController(req, res) { + const id = Number(req.params.id); + const pet = await fetchPetById(id); + + if (!pet) { + throw new NoDataError(`no pet with id: ${id}`); + } + res.status(200).json({ pet }); +} + +async function updatePetByIdController(req, res) { + const id = Number(req.params.id); + const updatedParams = req.body; + + const found = await fetchPetById(id) + if (!found) { + throw new NoDataError(`no pet with id: ${id}`); + } + + const pet = await updatePetById(id, updatedParams); + res.status(201).json({ pet }); +} + +async function addPetController(req, res) { + const newPet = req.body; + + const requiredFields = ["name", "age", "type", "breed", "has_microchip"]; + const missingFields = []; + requiredFields.forEach((field) => { + if (!newPet[field]) { + missingFields.push(field); + } + }); + if (missingFields.length > 0) { + throw new MissingFieldsError( + `missing fields: ${missingFields.toString().replaceAll(",", ", ")}` + ); + } + + const pet = await addPet(newPet); + res.status(201).json({ pet }); +} + +async function deletePetController(req, res) { + const id = Number(req.params.id); + + const found = await fetchPetById(id) + if (!found) { + throw new NoDataError(`no pet with id: ${id}`); + } + + const pet = await deletePet(id); + res.status(201).json({ pet }); +} + +module.exports = { + getPetsController, + getPetsByIdController, + updatePetByIdController, + addPetController, + deletePetController, +}; diff --git a/src/dal/bookRepository.js b/src/dal/bookRepository.js new file mode 100644 index 00000000..93d08473 --- /dev/null +++ b/src/dal/bookRepository.js @@ -0,0 +1,101 @@ +const db = require("../../db/index.js"); + +async function fetchAllBooks() { + try { + const result = await db.query("SELECT * FROM books"); + return result.rows; + } catch (e) { + console.log(e); + } +} + +async function fetchBookByQuery(query) { + let sqlQuery = "SELECT * FROM books"; + const params = []; + const perPage = query.perPage || 20; + + if (query.author) { + params.push(query.author); + sqlQuery += ` WHERE author = $${params.length}`; + } + + params.push(perPage); + sqlQuery += ` LIMIT $${params.length}`; + + if (query.page) { + params.push((query.page - 1) * query.perPage); + sqlQuery += ` OFFSET $${params.length}`; + } + + try { + const result = await db.query(sqlQuery, params); + return result.rows; + } catch (e) { + console.log(e); + } +} + +async function postBook(book) { + try { + const sqlQuery = `INSERT INTO books (title, type, author, topic, publication_date, pages) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *;`; + const result = await db.query(sqlQuery, [ + book.title, + book.type, + book.author, + book.topic, + book.publication_date, + book.pages, + ]); + return result.rows[0]; + } catch (e) { + console.log(e); + } +} + +async function fetchBookById(id) { + try { + const sqlQuery = "SELECT * FROM books WHERE id = $1;"; + const result = await db.query(sqlQuery, [id]); + return result.rows[0]; + } catch (e) { + console.log(e); + } +} + +async function updateBookById(id, newParams) { + try { + const sqlQuery = + "UPDATE books SET title = $2, type = $3, author = $4, topic = $5, publication_date = $6, pages = $7 WHERE id = $1 RETURNING *;"; + const result = await db.query(sqlQuery, [ + id, + newParams.title, + newParams.type, + newParams.author, + newParams.topic, + newParams.publication_date, + newParams.pages, + ]); + return result.rows[0]; + } catch (e) { + console.log(e); + } +} + +async function deleteBookById(id) { + try { + const sqlQuery = "DELETE FROM books WHERE id = $1 RETURNING *;"; + const result = await db.query(sqlQuery, [id]); + return result.rows[0]; + } catch (e) { + console.log(e); + } +} + +module.exports = { + fetchAllBooks, + postBook, + fetchBookById, + updateBookById, + deleteBookById, + fetchBookByQuery, +}; diff --git a/src/dal/breedsRepository.js b/src/dal/breedsRepository.js new file mode 100644 index 00000000..d9888489 --- /dev/null +++ b/src/dal/breedsRepository.js @@ -0,0 +1,10 @@ +const db = require("../../db/index.js"); + +async function fetchBreeds(query) { + + sqlQuery = 'SELECT breed FROM pets WHERE type = $1 GROUP BY breed' + const result = await db.query(sqlQuery, [query.type]) + return result.rows +} + +module.exports = { fetchBreeds } \ No newline at end of file diff --git a/src/dal/petsRepository.js b/src/dal/petsRepository.js new file mode 100644 index 00000000..00caa6fe --- /dev/null +++ b/src/dal/petsRepository.js @@ -0,0 +1,100 @@ +const db = require("../../db/index.js"); + +async function fetchAllPets() { + const sqlQuery = "SELECT * FROM pets LIMIT 20"; + + try { + const pets = await db.query(sqlQuery); + return pets.rows; + } catch (e) { + console.log(e); + } +} + +async function fetchPetsWithQuery(query) { + let sqlQuery = `SELECT * FROM pets`; + const params = []; + const perPage = query.perPage || 20; + + params.push(perPage); + sqlQuery += ` LIMIT $${params.length}`; + + if (query.page) { + params.push((query.page - 1) * query.perPage); + sqlQuery += ` OFFSET $${params.length}`; + } + + try { + const result = await db.query(sqlQuery, params); + return result.rows; + } catch (e) { + console.log(e); + } +} + +async function fetchPetById(id) { + const sqlQuery = "SELECT * FROM pets WHERE id = $1"; + + try { + const pets = await db.query(sqlQuery, [id]); + return pets.rows[0]; + } catch (e) { + console.log(e); + } +} + +async function updatePetById(id, updatedParams) { + const sqlQuery = + "UPDATE pets SET name = $1, age = $2, type = $3, breed = $4, has_microchip = $5 RETURNING *;"; + + try { + const result = await db.query(sqlQuery, [ + updatedParams.name, + updatedParams.age, + updatedParams.type, + updatedParams.breed, + updatedParams.has_microchip, + ]); + return result.rows[0]; + } catch (e) { + console.log(e); + } +} + +async function addPet(newPet) { + const sqlQuery = + "INSERT INTO pets (name, age, type, breed, has_microchip) VALUES ($1, $2, $3, $4, $5) RETURNING *"; + + try { + const result = await db.query(sqlQuery, [ + newPet.name, + newPet.age, + newPet.type, + newPet.breed, + newPet.has_microchip, + ]); + return result.rows[0]; + } catch (e) { + console.log(e); + } +} + +async function deletePet(petId) { + const sqlQuery = "DELETE FROM pets WHERE id = $1 RETURNING *"; + + try { + const result = await db.query(sqlQuery, [petId]); + return result.rows[0]; + } catch (e) { + console.log(e); + } +} + +module.exports = { + fetchAllPets, + fetchPetById, + updatePetById, + addPet, + deletePet, + fetchPetsWithQuery, +}; diff --git a/src/errors/errors.js b/src/errors/errors.js new file mode 100644 index 00000000..09d80724 --- /dev/null +++ b/src/errors/errors.js @@ -0,0 +1,16 @@ +class MissingFieldsError extends Error { +} + +class NoDataError extends Error { + +} + +class InvalidParameterError extends Error { + +} + +class DataAlreadyExistsError extends Error { + +} + +module.exports = { MissingFieldsError, NoDataError, InvalidParameterError, DataAlreadyExistsError } \ No newline at end of file diff --git a/src/routers/books.js b/src/routers/books.js index 1551dd87..23971ba9 100644 --- a/src/routers/books.js +++ b/src/routers/books.js @@ -1,9 +1,16 @@ const express = require('express') const router = express.Router() -const db = require("../../db"); -router.get('/', async (req, res) => { +const { addBookController, getBooksController, getBookByIdController, putBookByIdController, deleteBookByIdController } = require('../controllers/booksControllers') -}) +router.get('/', getBooksController) + +router.post('/', addBookController) + +router.get('/:id', getBookByIdController) + +router.put('/:id', putBookByIdController) + +router.delete('/:id', deleteBookByIdController) module.exports = router diff --git a/src/routers/breeds.js b/src/routers/breeds.js new file mode 100644 index 00000000..c27834ee --- /dev/null +++ b/src/routers/breeds.js @@ -0,0 +1,8 @@ +const express = require('express') +const router = express.Router() + +const { getAllBreedsController } = require('../controllers/breedsControllers') + +router.use('/', getAllBreedsController) + +module.exports = router \ No newline at end of file diff --git a/src/routers/pets.js b/src/routers/pets.js new file mode 100644 index 00000000..56930e62 --- /dev/null +++ b/src/routers/pets.js @@ -0,0 +1,16 @@ +const express = require('express') +const router = express.Router() + +const { getPetsController, getPetsByIdController, updatePetByIdController, addPetController, deletePetController } = require('../controllers/petsControllers') + +router.get('/', getPetsController) + +router.get('/:id', getPetsByIdController) + +router.put('/:id', updatePetByIdController) + +router.post('/', addPetController) + +router.delete('/:id', deletePetController) + +module.exports = router \ No newline at end of file diff --git a/src/server.js b/src/server.js index dac55e5d..24a3fc33 100644 --- a/src/server.js +++ b/src/server.js @@ -1,3 +1,5 @@ +require('dotenv').config() +require('express-async-errors') const express = require("express"); const morgan = require("morgan"); const cors = require("cors"); @@ -10,7 +12,34 @@ app.use(express.json()); //TODO: Implement books and pets APIs using Express Modular Routers const booksRouter = require('./routers/books.js') - +const petsRouter = require('./routers/pets.js') +const breedsRouter = require('./routers/breeds.js') app.use('/books', booksRouter) +app.use('/pets', petsRouter) +app.use('/breeds', breedsRouter) + +//Error handling +const { MissingFieldsError, NoDataError, InvalidParameterError, DataAlreadyExistsError } = require('./errors/errors.js') + +app.use((error, req, res, next) => { + if (error instanceof MissingFieldsError) { + return res.status(400).json({error: error.message}) + } + if (error instanceof NoDataError) { + return res.status(404).json({error: error.message}) + } + + if (error instanceof InvalidParameterError) { + return res.status(400).json({error: error.message}) + } + + if (error instanceof DataAlreadyExistsError) { + return res.status(409).json({error: error.message}) + } + console.log(`Unhandled error`, error) + res.status(500).json({ + message: 'Something went wrong' + }) +}) module.exports = app diff --git a/utils/dbConnection.js b/utils/dbConnection.js new file mode 100644 index 00000000..28dc327d --- /dev/null +++ b/utils/dbConnection.js @@ -0,0 +1,16 @@ +const { Pool } = require('pg'); + +const { PGHOST, PGDATABASE, PGUSER, PGPASSWORD } = process.env; + +const dbConnection = new Pool({ + host: PGHOST, + database: PGDATABASE, + username: PGUSER, + password: PGPASSWORD, + port: 5432, + ssl: { + require: true, + }, +}) + +module.exports = dbConnection \ No newline at end of file