├── .gitignore ├── src ├── plugins │ └── jwtPlugin.js ├── routes │ ├── project.routes.js │ └── user.routes.js ├── models │ ├── project.model.js │ └── user.model.js ├── app.js ├── controllers │ ├── user.controller.js │ └── project.controller.js └── middlewares │ └── auth.js ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules/ -------------------------------------------------------------------------------- /src/plugins/jwtPlugin.js: -------------------------------------------------------------------------------- 1 | const fp = require("fastify-plugin") 2 | 3 | module.exports = fp(async function (fastify, opts) { 4 | fastify.register(require("@fastify/jwt"), { 5 | secret: process.env.JWT_SIGNING_SECRET 6 | }) 7 | 8 | fastify.decorate("jwtAuth", async function (request, reply) { 9 | try { 10 | await request.jwtVerify(); 11 | } catch (err) { 12 | reply.status(401).send({ message: "Unauthorized" }) 13 | } 14 | }) 15 | }) -------------------------------------------------------------------------------- /src/routes/project.routes.js: -------------------------------------------------------------------------------- 1 | const projectController = require("../controllers/project.controller"); 2 | 3 | async function routes(fastify, options) { 4 | fastify.post("/", projectController.createProject); 5 | fastify.get("/", projectController.getAllProjects); 6 | fastify.get("/:id", projectController.getProjectById); 7 | fastify.put("/:id", projectController.updateProject); 8 | fastify.delete("/:id", projectController.deleteProject); 9 | } 10 | 11 | module.exports = routes; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "building-an-api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node src/app.js", 9 | "dev": "node --watch src/app.js" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@fastify/jwt": "^8.0.1", 16 | "bcrypt": "^5.1.0", 17 | "dotenv": "^16.0.3", 18 | "fastify": "^4.17.0", 19 | "fastify-plugin": "^4.5.1", 20 | "mongoose": "^7.2.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/models/project.model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const User = require("./user.model"); 3 | 4 | const ProjectSchema = new mongoose.Schema({ 5 | title: { 6 | type: String, 7 | required: true, 8 | trim: true, 9 | }, 10 | description: { 11 | type: String, 12 | trim: true, 13 | }, 14 | startDate: { 15 | type: Date, 16 | required: true, 17 | }, 18 | endDate: { 19 | type: Date, 20 | required: true, 21 | }, 22 | projectManager: { 23 | type: mongoose.Schema.Types.ObjectId, 24 | ref: "User", 25 | required: true, 26 | validate: { 27 | validator: async function (v) { 28 | const user = await User.findById(v); 29 | return ["Admin", "Project Manager"].includes(user.role); 30 | }, 31 | message: (props) => 32 | `User role must be either 'Admin' or 'Project Manager'.`, 33 | }, 34 | }, 35 | teamMembers: [ 36 | { 37 | type: mongoose.Schema.Types.ObjectId, 38 | ref: "User", 39 | }, 40 | ], 41 | }); 42 | 43 | const Project = mongoose.model("Project", ProjectSchema); 44 | 45 | module.exports = Project; 46 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const fastify = require("fastify")({ logger: false }); 2 | const mongoose = require("mongoose"); 3 | require("dotenv").config(); 4 | const jwtPlugin = require("./plugins/jwtPlugin"); 5 | 6 | const { basicAuth } = require("./middlewares/auth") 7 | 8 | fastify.register(jwtPlugin) 9 | 10 | // Import my routes 11 | const userRoutes = require("./routes/user.routes"); 12 | const projectRoutes = require("./routes/project.routes"); 13 | // Connect to my database 14 | mongoose 15 | .connect(process.env.MONGODB_URI, { 16 | useNewUrlParser: true, 17 | useUnifiedTopology: true, 18 | }) 19 | .then(() => console.log("Connected to the database")) 20 | .catch((e) => console.log("Error connecting to database", e)); 21 | 22 | // start my server 23 | fastify.register(userRoutes, { prefix: "/api/v1/users" }); 24 | fastify.register(projectRoutes, { prefix: "/api/v1/projects" }); 25 | 26 | // fastify.addHook("preHandler", basicAuth); 27 | 28 | 29 | const start = async () => { 30 | try { 31 | fastify.listen({ port: process.env.PORT || 5000 }); 32 | fastify.log.info( 33 | `Server is running on port ${fastify.server.address().port}` 34 | ); 35 | } catch (error) { } 36 | }; 37 | 38 | start(); 39 | -------------------------------------------------------------------------------- /src/models/user.model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const bcrypt = require("bcrypt"); 3 | 4 | const UserSchema = new mongoose.Schema({ 5 | firstName: { 6 | type: String, 7 | required: true, 8 | trim: true, 9 | }, 10 | lastName: { 11 | type: String, 12 | required: true, 13 | trim: true, 14 | }, 15 | email: { 16 | type: String, 17 | required: true, 18 | trim: true, 19 | unique: true, 20 | lowercase: true, 21 | }, 22 | role: { 23 | type: String, 24 | enum: ["Admin", "Project Manager", "Team Member"], 25 | default: "Team member", 26 | }, 27 | password: { 28 | type: String, 29 | required: true, 30 | select: false 31 | } 32 | }); 33 | 34 | UserSchema.pre("save", async function (next) { 35 | try { 36 | if (this.isModified('password') || this.isNew) { 37 | const salt = await bcrypt.genSalt(10); 38 | this.password = await bcrypt.hash(this.password, salt) 39 | } 40 | } catch (error) { 41 | next(error) 42 | } 43 | }) 44 | 45 | UserSchema.methods.comparePassword = async function (password) { 46 | try { 47 | return await bcrypt.compare(password, this.password) 48 | } catch (error) { 49 | console.log(error) 50 | throw error; 51 | } 52 | } 53 | 54 | const User = mongoose.model("User", UserSchema); 55 | 56 | module.exports = User; 57 | -------------------------------------------------------------------------------- /src/controllers/user.controller.js: -------------------------------------------------------------------------------- 1 | const User = require("../models/user.model"); 2 | 3 | async function getAllUsers(request, reply) { 4 | try { 5 | const users = await User.find(); 6 | reply.send(users); 7 | } catch (error) { 8 | reply.status(500).send(error); 9 | } 10 | } 11 | async function getUserById(request, reply) { 12 | try { 13 | const user = await User.findById(request.params.id); 14 | reply.send(user); 15 | } catch (error) { 16 | reply.status(500).send(error); 17 | } 18 | } 19 | async function createUser(request, reply) { 20 | try { 21 | const user = new User(request.body); 22 | const result = await user.save(); 23 | reply.send(result); 24 | } catch (error) { 25 | reply.status(500).send(error); 26 | } 27 | } 28 | async function updateUser(request, reply) { 29 | try { 30 | const user = await User.findByIdAndUpdate(request.params.id, request.body, { 31 | new: true, 32 | }); 33 | reply.send(user); 34 | } catch (error) { 35 | reply.status(500).send(error); 36 | } 37 | } 38 | async function deleteUser(request, reply) { 39 | try { 40 | await User.findByIdAndDelete(request.params.id); 41 | reply.status(204).send(""); 42 | } catch (error) { 43 | reply.status(500).send(error); 44 | } 45 | } 46 | 47 | module.exports = { 48 | getAllUsers, 49 | getUserById, 50 | createUser, 51 | updateUser, 52 | deleteUser, 53 | }; 54 | -------------------------------------------------------------------------------- /src/middlewares/auth.js: -------------------------------------------------------------------------------- 1 | const User = require('../models/user.model'); 2 | 3 | require('dotenv').config() 4 | 5 | async function apiKeyAuth(request, reply) { 6 | if (['GET', 'HEAD'].includes(request.method)) { 7 | return; 8 | } 9 | const apiKey = request.headers['x-api-key']; 10 | const knownKey = process.env.APIKEY 11 | 12 | if (!apiKey || apiKey !== knownKey) { 13 | return reply.code(401).send({ error: "Unauthorized" }) 14 | } 15 | } 16 | 17 | async function basicAuth(request, reply) { 18 | const authHeader = request.headers['authorization']; 19 | 20 | if (!authHeader) { 21 | return reply.status(401).send({ error: "No authorization header" }) 22 | } 23 | 24 | const [authType, authKey] = authHeader.split(" "); 25 | 26 | if (authType !== 'Basic') { 27 | return reply.status(401).send({ error: "Requires basic auth (username/password)" }) 28 | } 29 | 30 | const [email, password] = Buffer.from(authKey, 'base64').toString('ascii').split(":") 31 | 32 | try { 33 | const user = await User.findOne({ email }).select("password") 34 | 35 | if (!user) { 36 | return reply.status(401).send({ error: "User not found." }); 37 | } 38 | 39 | const isMatch = await user.comparePassword(password); 40 | 41 | if (!isMatch) { 42 | return reply.status(401).send({ error: "Incorrect password." }); 43 | } 44 | } catch (error) { 45 | console.log(error) 46 | return reply.status(500).send({ error: "An error occurred during authorization." }) 47 | } 48 | } 49 | 50 | module.exports = { apiKeyAuth, basicAuth } -------------------------------------------------------------------------------- /src/routes/user.routes.js: -------------------------------------------------------------------------------- 1 | const userController = require("../controllers/user.controller"); 2 | const { basicAuth } = require("../middlewares/auth"); 3 | const User = require("../models/user.model") 4 | 5 | async function routes(fastify, options) { 6 | fastify.get("/", { onRequest: [fastify.jwtAuth] }, userController.getAllUsers); 7 | fastify.get("/:id", userController.getUserById); 8 | fastify.post("/", userController.createUser); 9 | fastify.put("/:id", userController.updateUser); 10 | fastify.delete("/:id", userController.deleteUser); 11 | fastify.post("/login", async (request, reply) => { 12 | const { email, password } = request.body; 13 | // check the password/username 14 | try { 15 | const user = await User.findOne({ email }).select(["password", "role", "firstName", "lastName"]) 16 | 17 | if (!user) { 18 | return reply.status(401).send({ error: "User not found." }); 19 | } 20 | 21 | const isMatch = await user.comparePassword(password); 22 | 23 | if (!isMatch) { 24 | return reply.status(401).send({ error: "Incorrect password." }); 25 | } 26 | 27 | // if valid -> sign a token 28 | const token = fastify.jwt.sign({ 29 | payload: { 30 | email, firstName: user.firstName, lastName: user.lastName, role: user.role 31 | } 32 | }) 33 | // which we'll return 34 | reply.send({ token }) 35 | } catch (error) { 36 | console.log(error) 37 | return reply.status(500).send({ error: "An error occurred during authorization." }) 38 | } 39 | 40 | }) 41 | } 42 | 43 | module.exports = routes; 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fastify API 2 | 3 | This repo accompanies a video series on building fastify API development. 4 | 5 | Each video has an accompanying branch. This is the finished state of the application at the end of the video. 6 | 7 | At the moment, the main branch is the latest version of the application. 8 | 9 | | Branch | Video | 10 | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 11 | | first-video | [Building a RESTful API with Node.js, Fastify, MongoDB and Mongoose: Part 1](https://www.youtube.com/watch?v=ACVBMrgdXgE&list=PLxRcI0Ere7NZdZ0auFaayxOb51PD6Tyic) | 12 | | second-video | [Building a CRUD API with Fastify and MongoDB: Part 2 - Handling Related Data](https://www.youtube.com/watch?v=4urB0pfcBt8&list=PLxRcI0Ere7NZdZ0auFaayxOb51PD6Tyic&index=2) | 13 | | third-video | [Master Fastify Authorization: Protect Your API Routes](https://www.youtube.com/watch?v=JwCu0oTK-OI&list=PLxRcI0Ere7NZdZ0auFaayxOb51PD6Tyic&index=3) | 14 | | fourth-video | [Securing API Routes: Basic Authentication Explained](https://www.youtube.com/watch?v=8hIp3qQJaMY&list=PLxRcI0Ere7NZdZ0auFaayxOb51PD6Tyic&index=4) | 15 | | fifth-video | [Protecting Fastify Routes with JWTs]() | 16 | 17 | --- 18 | 19 | I am keen to add more videos to this series - either comment on the YouTube videos or add an issue to this repo to let me know what you'd like to see. 20 | -------------------------------------------------------------------------------- /src/controllers/project.controller.js: -------------------------------------------------------------------------------- 1 | const Project = require("../models/project.model"); 2 | const User = require("../models/user.model"); 3 | 4 | async function createProject(request, reply) { 5 | try { 6 | // confirm that the projectManager id exists in the database 7 | // and is valid 8 | const projectManager = await User.findById(request.body.projectManager); 9 | if ( 10 | !projectManager || 11 | !["Admin", "Project Manager"].includes(projectManager.role) 12 | ) { 13 | return reply.status(400).send({ message: "Invalid project manager" }); 14 | } 15 | 16 | // verify that the team members exist 17 | // and I'll send reply if one of them is wrong 18 | for (let memberId of request.body.teamMembers) { 19 | const teamMember = await User.findById(memberId); 20 | if (!teamMember) { 21 | return reply 22 | .status(400) 23 | .send({ message: `Invalid team member: ${memberId}` }); 24 | } 25 | } 26 | 27 | // create my project 28 | const project = new Project(request.body); 29 | await project.save(); 30 | // return that 31 | reply.send(project); 32 | } catch (error) { 33 | reply.status(400).send(error); 34 | } 35 | } 36 | 37 | async function getAllProjects(request, reply) { 38 | try { 39 | const projects = await Project.find() 40 | .populate("projectManager", "firstName lastName email") 41 | .populate("teamMembers", "firstName lastName email role"); 42 | reply.send(projects); 43 | } catch (error) { 44 | reply.status(400).send(error); 45 | } 46 | } 47 | 48 | async function getProjectById(request, reply) { 49 | try { 50 | const project = await Project.findById(request.params.id); 51 | if (!project) { 52 | reply.status(404).send({ message: "Project with that id not found" }); 53 | } 54 | reply.send(project); 55 | } catch (error) { 56 | reply.status(400).send(error); 57 | } 58 | } 59 | 60 | async function updateProject(request, reply) { 61 | try { 62 | const projectId = request.params.id; 63 | const updates = request.body; 64 | 65 | if (updates.projectManager) { 66 | const projectManager = await User.findById(updates.projectManager); 67 | if ( 68 | !projectManager || 69 | !["Admin", "Project Manager"].includes(projectManager.role) 70 | ) { 71 | return reply.status(400).send({ message: "Invalid project manager" }); 72 | } 73 | } 74 | 75 | // verify that the team members exist 76 | // and I'll send reply if one of them is wrong 77 | if (updates.teamMembers) { 78 | for (let memberId of updates.teamMembers) { 79 | const teamMember = await User.findById(memberId); 80 | if (!teamMember) { 81 | return reply 82 | .status(400) 83 | .send({ message: `Invalid team member: ${memberId}` }); 84 | } 85 | } 86 | } 87 | 88 | const updatedProject = await Project.findByIdAndUpdate(projectId, updates, { 89 | new: true, 90 | }); 91 | 92 | if (!updatedProject) { 93 | return reply 94 | .status(404) 95 | .send({ message: "No project with that id found" }); 96 | } 97 | 98 | reply.send(updatedProject); 99 | } catch (error) { 100 | reply.status(400).send(error); 101 | } 102 | } 103 | 104 | async function deleteProject(request, reply) { 105 | try { 106 | const deletedProject = await Project.findByIdAndDelete(request.params.id); 107 | 108 | if (!deletedProject) { 109 | return reply 110 | .status(404) 111 | .send({ message: "No project with that id found" }); 112 | } 113 | 114 | reply.status(204).send(""); 115 | } catch (error) { 116 | reply.status(400).send(error); 117 | } 118 | } 119 | 120 | module.exports = { 121 | createProject, 122 | getAllProjects, 123 | getProjectById, 124 | updateProject, 125 | deleteProject, 126 | }; 127 | --------------------------------------------------------------------------------