functional RESTful API

This commit is contained in:
ak 2023-09-18 13:43:22 -07:00
parent e5ac1a96bb
commit 7ea24df56b
14 changed files with 2677 additions and 1 deletions

65
.gitignore vendored Normal file
View file

@ -0,0 +1,65 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# next.js build output
.next
# deta-space internal files
.space
Spacefile

View file

@ -1,3 +1,5 @@
# express-blog-api # express-blog-api
Backend with two different front-ends for accessing and editing blog posts. One of the front-end sites will be for people that want to read and comment on posts while the other one will allow editing, writing and publishing of posts. RESTful blog API implemented in Express. Has POST/GET/PUT/DELETE methods for all posts and comments.
Post Creation, Updates and Deletion require jwt token returned when post completed at /api/login.
Hosted on Deta Space at https://expressblogapi-1-v2871156.deta.app/api/

41
app.js Normal file
View file

@ -0,0 +1,41 @@
const express = require("express");
const path = require("path");
const cors = require("cors");
const mongoose = require("mongoose");
require("dotenv").config();
const apiRouter = require("./routes/api.js");
const app = express();
// get db
const mongoDB = `mongodb+srv://${process.env.USER}:${process.env.PASS}@odin.eftl02o.mongodb.net/blogAPI?retryWrites=true&w=majority`;
// or throw error
main().catch((err) => console.log(err));
async function main() {
await mongoose.connect(mongoDB);
}
// set paths
app.use(
"/styles/css",
express.static(path.join(__dirname, "node_modules/bootstrap/dist/css"))
);
app.use(express.static(path.join(__dirname, "public")));
// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "ejs");
// parsing
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, "public")));
// cors
app.use(cors());
// routing
app.use("/api", apiRouter);
module.exports = app;

90
bin/www Executable file
View file

@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('express-blog-api:server');
var http = require('http');
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
var server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
var port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
debug('Listening on ' + bind);
}

155
controllers/comment.js Normal file
View file

@ -0,0 +1,155 @@
const { default: mongoose } = require("mongoose");
const asyncHandler = require("express-async-handler");
const { body, validationResult } = require("express-validator");
const Comment = require("../models/comment.js");
// makes new comment - C
exports.post = [
// Validate and sanitize text
body("text", "Please enter comment!").isLength({ min: 1 }).trim().escape(),
// Validate and sanitize text
body("author", "Please enter comment author!")
.isLength({ min: 1 })
.trim()
.escape(),
// Validate and sanitize password
body("password", "Please enter password for future comment modification!")
.isLength({ min: 1 })
.trim()
.escape(),
// Process request after authentication, validation and sanitization
asyncHandler(async (req, res, next) => {
const errors = validationResult(req);
// if there are validation errors, render with errors
if (!errors.isEmpty()) {
return res.status(400).json({
message: "Comment produced validation errors!",
errors: errors.array(),
});
}
// else data is valid, make post object
const comment = new Comment({
date: new Date(),
text: req.body.text,
author: req.body.author,
post: req.params.postID,
_id: new mongoose.Types.ObjectId(),
password: req.body.password,
});
// save to DB
await comment.save();
return res.status(200).json({
message: "Comment uploaded!",
});
}),
];
// returns comment in json format - R
exports.get = asyncHandler(async (req, res, next) => {
const comment = await Comment.findOne({ _id: req.params.commentID })
.lean()
.exec();
return res.status(200).json({ comment });
});
// updates comment - U
exports.put = [
// Validate and sanitize text
body("text", "Please enter comment!").isLength({ min: 1 }).trim().escape(),
// Validate and sanitize author name
body("author", "Please enter comment author!")
.isLength({ min: 1 })
.trim()
.escape(),
// Validate and sanitize password
body("password", "Please enter password to modify comment!")
.isLength({ min: 1 })
.trim()
.escape(),
// Process request after sanitization and validation
asyncHandler(async (req, res, next) => {
const errors = validationResult(req);
// if there are validation errors, render with errors
if (!errors.isEmpty()) {
return res.status(400).json({
message: "Comment produced validation errors!",
errors: errors.array(),
});
}
const dbComment = await Comment.findOne({ _id: req.params.commentID })
.lean()
.exec();
if (req.body.author === dbComment.author) {
if (req.body.password === dbComment.password) {
const comment = {
date: new Date(),
text: req.body.text,
author: req.body.author,
post: dbComment.post,
_id: dbComment._id,
password: dbComment.password,
};
await comment.save();
return res.status(200).json({
message: "Post updated!",
});
}
return res.status(401).json({
message: "Comments can only be updated with their original password!",
});
}
return res.status(401).json({
message: "Comments can only be updated by their original author!",
});
}),
];
// deletes a comment - D
exports.delete = [
// Validate and sanitize password
body("password", "Please enter comment deletion password!")
.isLength({ min: 1 })
.trim()
.escape(),
// Process request after authentication, validation and sanitization
asyncHandler(async (req, res, next) => {
const errors = validationResult(req);
// if there are validation errors, render with errors
if (!errors.isEmpty()) {
return res.status(400).json({
message: "Comment password produced validation errors!",
errors: errors.array(),
});
}
const dbComment = await Comment.findOne({ _id: req.params.commentID })
.lean()
.exec();
if (req.body.password === dbComment.password) {
await Comment.findByIdAndDelete({ _id: req.params.commentID }).exec();
return res.status(200).json({ message: "Comment deleted!" });
}
return res.status(401).json({
message: "Incorrect password!",
});
}),
];

24
controllers/login.js Normal file
View file

@ -0,0 +1,24 @@
const asyncHandler = require("express-async-handler");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const User = require("../models/user.js");
let opts = {};
exports.post = asyncHandler(async (req, res, next) => {
const admin = await User.findOne({}).lean().exec(); // only one user in DB - admin. pull it up, jamie!
const { username, password } = req.body; // get fields from body
if (username === admin.username) {
console.log();
const match = await bcrypt.compare(password, admin.password); // compare bcrypt hashed passwords
if (match) {
opts.expiresIn = 120;
const token = jwt.sign({ username }, process.env.SECRET_KEY, opts); // create token and return below
return res.status(200).json({
message: "Authentication complete",
token,
});
}
}
return res.status(401).json({ message: "Authentication failed" }); // get rekt nerd
});

124
controllers/post.js Normal file
View file

@ -0,0 +1,124 @@
const { default: mongoose } = require("mongoose");
const asyncHandler = require("express-async-handler");
const { body, validationResult } = require("express-validator");
const Post = require("../models/post.js");
const Comment = require("../models/comment.js");
const passport = require("passport");
const jwtStrategy = require("../strategy/jwt.js");
passport.use(jwtStrategy);
// returns json object with ALL posts and comments
exports.index = asyncHandler(async (req, res, next) => {
const dbPosts = await Post.find().lean().exec();
const posts = [];
for (let i = 0; i < dbPosts.length; i++) {
const comments = await Comment.find({ post: dbPosts[i]._id });
const post = {
title: dbPosts[i].title,
date: dbPosts[i].date,
text: dbPosts[i].text,
author: dbPosts[i].author,
published: dbPosts[i].published,
_id: dbPosts[i]._id,
comments: comments,
};
posts.push(post);
}
return res.status(200).json({ posts });
});
// makes new post - C
(exports.post = passport.authenticate("jwt", { session: false })),
[
// Validate and sanitize title
body("title", "Please enter blog post title!")
.isLength({ min: 1 })
.trim()
.escape(),
// Validate and sanitize text
body("text", "Please enter blog post text!")
.isLength({ min: 1 })
.trim()
.escape(),
// Process request after authentication, validation and sanitization
asyncHandler(async (req, res, next) => {
const errors = validationResult(req);
// if there are validation errors, render with errors
if (!errors.isEmpty()) {
res.render("/admin/create"),
{
errors: errors.array(),
};
return;
}
// else data is valid, make post object
const post = new Post({
title: req.body.title,
date: new Date(),
text: req.body.text,
author: req.user.username,
_id: new mongoose.Types.ObjectId(),
});
// save to DB
await post.save();
return res.status(200).json({
message: "Post created!",
});
}),
];
// returns post in json format - R
exports.get = asyncHandler(async (req, res, next) => {
const post = await Post.findOne({ _id: req.params.postID }).lean().exec();
return res.status(200).json({ post });
});
// updates post - U
(exports.put = passport.authenticate("jwt", { session: false })),
[
// Validate and sanitize title
body("title", "Please enter blog post title!")
.isLength({ min: 1 })
.trim()
.escape(),
// Validate and sanitize text
body("text", "Please enter blog post text!")
.isLength({ min: 1 })
.trim()
.escape(),
// Process request after sanitization and validation
asyncHandler(async (req, res, next) => {
const dbPost = await Post.findOne({ _id: req.params.postID })
.lean()
.exec();
const post = {
title: req.body.title,
date: new Date(),
text: req.body.text,
author: dbPost.author,
published: dbPost.published,
_id: dbPost._id,
};
await post.save();
return res.status(200).json({
message: "Post updated!",
});
}),
];
// deletes a post - D
(exports.delete = passport.authenticate("jwt", { session: false })),
asyncHandler(async (req, res, next) => {
await Post.findByIdAndDelete({ _id: req.params.postID }).exec();
return res.status(200).json({ message: "Post deleted!" });
});

20
models/comment.js Normal file
View file

@ -0,0 +1,20 @@
const mongoose = require("mongoose");
const Schema = mongoose.Schema;
const CommentSchema = new Schema({
date: { type: Date, required: true },
text: { type: String, required: true },
author: { type: String, required: true },
post: { type: mongoose.ObjectId, required: true },
_id: { type: mongoose.ObjectId, required: true },
password: { type: String, required: true },
});
// Virtual for comment RESTful functions
CommentSchema.virtual("url").get(function () {
// We don't use an arrow function as we'll need the this object
return `/api/${post}/${this._id}`;
});
// Export model
module.exports = mongoose.model("Comment", CommentSchema);

20
models/post.js Normal file
View file

@ -0,0 +1,20 @@
const mongoose = require("mongoose");
const Schema = mongoose.Schema;
const PostSchema = new Schema({
title: { type: String, required: true },
date: { type: Date, required: true },
text: { type: String, required: true },
author: { type: String, required: true },
published: { type: Boolean, required: true },
_id: { type: mongoose.ObjectId, required: true },
});
// Virtual for message URL
PostSchema.virtual("url").get(function () {
// We don't use an arrow function as we'll need the this object
return `/api/${this._id}`;
});
// Export model
module.exports = mongoose.model("Post", PostSchema);

12
models/user.js Normal file
View file

@ -0,0 +1,12 @@
const mongoose = require("mongoose");
const Schema = mongoose.Schema;
const UserSchema = new Schema({
username: { type: String, required: true },
password: { type: String, required: true },
});
// we will NOT be exposing any URLs for users
// Export model
module.exports = mongoose.model("User", UserSchema);

2039
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "express-blog-api",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node ./bin/www"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"bootstrap": "^5.3.1",
"cors": "^2.8.5",
"debug": "~2.6.9",
"dotenv": "^16.3.1",
"ejs": "^3.1.9",
"ejs-lint": "^2.0.0",
"express": "^4.18.2",
"express-async-handler": "^1.2.0",
"express-session": "^1.17.3",
"express-validator": "^7.0.1",
"jsonwebtoken": "^9.0.2",
"mongoose": "^7.5.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-local-mongoose": "^8.0.0"
}
}

37
routes/api.js Normal file
View file

@ -0,0 +1,37 @@
const express = require("express");
const router = express.Router();
const login_controller = require("../controllers/login.js");
const post_controller = require("../controllers/post.js");
const comment_controller = require("../controllers/comment.js");
// list all posts and append comments to each post based on id, return as json
router.get("/", post_controller.index);
// login page - should work with json
router.post("/login", login_controller.post);
// post post
router.post("/new_post", post_controller.post);
// post get
router.get("/:postID", post_controller.get);
// post put
router.put("/:postID", post_controller.put);
// post delete
router.delete("/:postID", post_controller.delete);
// comment post
router.post("/:postID/new_comment", comment_controller.post);
// comment get
router.get("/:postID/:commentID", comment_controller.get);
// comment put
router.put("/:postID/:commentID", comment_controller.put);
// comment delete
router.delete("/:postID/:commentID", comment_controller.delete);
module.exports = router;

20
strategy/jwt.js Normal file
View file

@ -0,0 +1,20 @@
const User = require("../models/user.js");
const JwtStrategy = require("passport-jwt").Strategy;
const ExtractJwt = require("passport-jwt").ExtractJwt;
const opts = {};
opts.jwtFromRequest = ExtractJwt.fromAuthHeaderAsBearerToken();
opts.secretOrKey = process.env.SECRET_KEY;
const getAdmin = async () => {
const admin = await User.findOne({}).lean().exec(); // only one user in DB - admin. pull it up, jamie!
return admin;
};
module.exports = new JwtStrategy(opts, (jwt_payload, done) => {
const admin = getAdmin();
if (jwt_payload.username === admin.username) {
return done(null, true);
}
return done(null, false);
});