├── .env.example ├── .gitignore ├── README.md ├── deps.ts ├── migrations ├── 1590147973261-create_users.ts └── 1590282089448-create_posts.ts ├── nessie.config.ts └── src ├── config.ts ├── controllers ├── post.ts └── user.ts ├── db.ts ├── helpers.ts ├── index.ts ├── middlewares.ts ├── models ├── post.ts └── user.ts └── types.ts /.env.example: -------------------------------------------------------------------------------- 1 | DB_HOST= 2 | DB_PORT= 3 | DB_USER= 4 | DB_DATABASE= 5 | DB_PASSWORD= 6 | JWT_SECRET= 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deno crud Api + jwt 2 | 3 | A simple boilerplate for Deno CRUD Api, with jwt authentication. 4 | 5 | ### Features 6 | 7 | - [x] simple CRUD operations. 8 | - [x] [Oak](https://github.com/oakserver/oak) framework. 9 | - [x] [deno-postgres](https://github.com/buildondata/deno-postgres) as postgres deriver 10 | - [x] [jwt](https://github.com/timonson/djwt) and [deno-bcrypt](https://github.com/JamesBroadberry/deno-bcrypt) for authentication. 11 | - [x] handle all errors 12 | - [x] using [yup](https://github.com/jquense/yup) for body data validation using [pika.dev](https://www.pika.dev/) 13 | - [ ] tests 14 | - [x] [deno-nessie](https://github.com/halvardssm/deno-nessie) for database migrations. 15 | - [ ] Docker 16 | - [ ] github actions (CI) 17 | 18 | ### Prerequisites 19 | 20 | - Deno v1. 21 | - Postgres 22 | 23 | ### Getting Started 24 | 25 | ```bash 26 | git clone https://github.com/22mahmoud/deno_crud_jwt.git 27 | 28 | cd deno_crud_jwt 29 | 30 | cp .env.example .env 31 | ``` 32 | 33 | before run the server fill .env values. 34 | 35 | ```bash 36 | # download & cache all deps 37 | deno cache ./deps.ts 38 | 39 | # migrate database after fill your env file 40 | deno run --allow-net --allow-read --allow-env https://deno.land/x/nessie/cli.ts migrate 41 | 42 | # run the server 43 | deno run --allow-net --allow-env --allow-read --unstable src/index.ts 44 | ``` 45 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export { config as envConfig } from "https://deno.land/x/dotenv/mod.ts"; 2 | // @deno-types="https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/master/types/yup/index.d.ts" 3 | import * as yup from "https://cdn.pika.dev/yup@^0.29.0"; 4 | export { yup }; 5 | import * as bcrypt from "https://deno.land/x/bcrypt/mod.ts"; 6 | export { bcrypt }; 7 | export { bgGreen, black } from "https://deno.land/std/fmt/colors.ts"; 8 | export { 9 | Application, 10 | Router, 11 | Context, 12 | RouterContext, 13 | } from "https://deno.land/x/oak/mod.ts"; 14 | export { Client as PostgresClient } from "https://deno.land/x/postgres/mod.ts"; 15 | export { ConnectionOptions } from "https://deno.land/x/postgres/connection_params.ts"; 16 | export { 17 | makeJwt, 18 | setExpiration, 19 | Jose, 20 | Payload, 21 | } from "https://deno.land/x/djwt/create.ts"; 22 | export { validateJwt } from "https://deno.land/x/djwt/validate.ts"; 23 | export { v4 as uuid } from "https://deno.land/std/uuid/mod.ts"; 24 | export { 25 | ClientPostgreSQL, 26 | nessieConfig, 27 | Migration, 28 | } from "https://deno.land/x/nessie/mod.ts"; 29 | export { Schema, dbDialects } from "https://deno.land/x/nessie/qb.ts"; 30 | -------------------------------------------------------------------------------- /migrations/1590147973261-create_users.ts: -------------------------------------------------------------------------------- 1 | import { Migration, Schema, dbDialects } from "../deps.ts"; 2 | 3 | const dialect: dbDialects = "pg"; 4 | 5 | export const up: Migration = () => { 6 | let query = new Schema(dialect).create("users", (table) => { 7 | table.uuid("id").primary(); 8 | table.text("name").notNullable(); 9 | table.text("email").notNullable().unique(); 10 | table.text("password").notNullable(); 11 | }); 12 | 13 | return query; 14 | }; 15 | 16 | export const down: Migration = () => { 17 | return new Schema(dialect).drop("users"); 18 | }; 19 | -------------------------------------------------------------------------------- /migrations/1590282089448-create_posts.ts: -------------------------------------------------------------------------------- 1 | import { Migration, Schema, dbDialects } from "../deps.ts"; 2 | 3 | const dialect: dbDialects = "pg"; 4 | 5 | export const up: Migration = () => { 6 | let query = new Schema(dialect).create("posts", (table) => { 7 | table.uuid("id").primary(); 8 | table.text("title").notNullable(); 9 | table.text("body").notNullable(); 10 | table.uuid("user_id").notNullable(); 11 | table.custom("foreign key (user_id) references users"); 12 | }); 13 | 14 | return query; 15 | }; 16 | 17 | export const down: Migration = () => { 18 | return new Schema(dialect).drop("posts"); 19 | }; 20 | -------------------------------------------------------------------------------- /nessie.config.ts: -------------------------------------------------------------------------------- 1 | import { nessieConfig, ClientPostgreSQL } from "./deps.ts"; 2 | import { config } from "./src/config.ts"; 3 | 4 | const configPg: nessieConfig = { 5 | client: new ClientPostgreSQL("./migrations", { 6 | ...config.dbConfig, 7 | }), 8 | }; 9 | 10 | export default configPg; 11 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import "https://deno.land/x/dotenv/load.ts"; 2 | import { Config } from "./types.ts"; 3 | import { ConnectionOptions, envConfig } from "../deps.ts"; 4 | 5 | envConfig(); 6 | 7 | const dbConfig: ConnectionOptions = { 8 | hostname: Deno.env.get("DB_HOST"), 9 | port: +Deno.env.get("DB_PORT")!, 10 | user: Deno.env.get("DB_USER"), 11 | database: Deno.env.get("DB_DATABASE"), 12 | password: Deno.env.get("DB_PASSWORD"), 13 | }; 14 | 15 | export const config: Config = { 16 | dbConfig, 17 | jwtSecret: Deno.env.get("JWT_SECRET")!, 18 | }; 19 | -------------------------------------------------------------------------------- /src/controllers/post.ts: -------------------------------------------------------------------------------- 1 | import { RouterContext, uuid, yup } from "../../deps.ts"; 2 | import { IPost } from "../types.ts"; 3 | import { Post } from "../models/post.ts"; 4 | 5 | const createPostSchema = yup.object({ 6 | title: yup.string().required(), 7 | body: yup.string().required() 8 | }); 9 | 10 | export async function getPosts(ctx: RouterContext) { 11 | try { 12 | const { response } = ctx; 13 | const posts = await Post.findAll(); 14 | response.status = 201; 15 | response.body = { 16 | data: posts 17 | }; 18 | } catch (error) { 19 | throw error; 20 | } 21 | } 22 | 23 | export async function getPost(ctx: RouterContext) { 24 | try { 25 | const { response, params } = ctx; 26 | const post = await Post.findOneById(params.id!); 27 | response.status = 201; 28 | response.body = { 29 | data: post 30 | }; 31 | } catch (error) { 32 | throw error; 33 | } 34 | } 35 | 36 | export async function createPost(ctx: RouterContext) { 37 | try { 38 | const { 39 | request, 40 | response, 41 | state: { user } 42 | } = ctx; 43 | if (!user) { 44 | response.status = 401; 45 | response.body = { 46 | message: "Unauthorized" 47 | }; 48 | return; 49 | } 50 | const body = await request.body(); 51 | const data: Omit = body.value; 52 | await createPostSchema.validate(data); 53 | const postId = uuid.generate(); 54 | 55 | const post = await Post.insert({ ...data, id: postId, userId: user.id }); 56 | response.status = 201; 57 | response.body = { 58 | message: "post created", 59 | data: post 60 | }; 61 | } catch (error) { 62 | throw error; 63 | } 64 | } 65 | 66 | export async function deletePost(ctx: RouterContext) { 67 | try { 68 | const { 69 | params, 70 | response, 71 | state: { user } 72 | } = ctx; 73 | if (!user) { 74 | response.status = 401; 75 | response.body = { 76 | message: "Unauthorized" 77 | }; 78 | return; 79 | } 80 | 81 | const post = await Post.findOneById(params.id || ""); 82 | if (post.user?.id !== user.id) { 83 | response.status = 403; 84 | response.body = { 85 | message: "you don't have access to delete this post" 86 | }; 87 | return; 88 | } 89 | await Post.delete(post.id || ""); 90 | response.status = 202; 91 | response.body = { 92 | message: "post deleted" 93 | }; 94 | } catch (error) { 95 | throw error; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/controllers/user.ts: -------------------------------------------------------------------------------- 1 | import { RouterContext, validateJwt, uuid, yup } from "../../deps.ts"; 2 | import { IUser } from "../types.ts"; 3 | import { User } from "../models/user.ts"; 4 | import { generateJwt } from "../helpers.ts"; 5 | import { config } from "../config.ts"; 6 | 7 | export async function hello(ctx: RouterContext) { 8 | const { response } = ctx; 9 | response.status = 200; 10 | response.body = { 11 | message: "Hello, World" 12 | }; 13 | } 14 | 15 | const signupSchema = yup.object({ 16 | email: yup 17 | .string() 18 | .email() 19 | .required(), 20 | password: yup.string().required(), 21 | name: yup.string().required() 22 | }); 23 | 24 | const loginSchema = yup.object({ 25 | email: yup 26 | .string() 27 | .email() 28 | .required(), 29 | password: yup.string().required() 30 | }); 31 | 32 | export async function signup(ctx: RouterContext) { 33 | const { request, response } = ctx; 34 | try { 35 | const body = await request.body(); 36 | 37 | const data: Omit = body.value; 38 | await signupSchema.validate(data); 39 | 40 | const userId = uuid.generate(); 41 | 42 | // check if the user with this email already registerd 43 | const user = await User.findOneByEmail(data.email); 44 | if (user) { 45 | response.status = 400; 46 | response.body = { 47 | message: `User with email : ${data.email} already exist` 48 | }; 49 | return; 50 | } 51 | 52 | const { id } = await User.insert({ ...data, id: userId }); 53 | const jwt = generateJwt(id); 54 | response.status = 201; 55 | response.body = { 56 | data: jwt 57 | }; 58 | } catch (error) { 59 | throw error; 60 | } 61 | } 62 | 63 | export async function login(ctx: RouterContext) { 64 | const { request, response } = ctx; 65 | try { 66 | const body = await request.body(); 67 | const data: Omit = body.value; 68 | await loginSchema.validate(data); 69 | 70 | const user = await User.comparePassword(data.email, data.password); 71 | if (!user) { 72 | response.status = 400; 73 | response.body = { 74 | message: "user not found or bad password" 75 | }; 76 | return; 77 | } 78 | 79 | const jwt = generateJwt(user.id); 80 | response.status = 201; 81 | response.body = { 82 | data: jwt 83 | }; 84 | } catch (error) { 85 | throw error; 86 | } 87 | } 88 | 89 | export async function me(ctx: RouterContext) { 90 | try { 91 | const { request, response } = ctx; 92 | const jwt = request.headers.get("authorization")?.split("bearer ")?.[1]; 93 | if (!jwt) { 94 | response.status = 401; 95 | response.body = { 96 | message: "Unauthorized" 97 | }; 98 | return; 99 | } 100 | 101 | const validatedJwt = await validateJwt(jwt, config.jwtSecret, { 102 | isThrowing: false 103 | }); 104 | 105 | if (!validatedJwt) { 106 | response.status = 401; 107 | response.body = { 108 | message: "Unauthorized" 109 | }; 110 | return; 111 | } 112 | 113 | const user = await User.findOneById(validatedJwt.payload?.id as string); 114 | if (!user) { 115 | response.status = 401; 116 | response.body = { 117 | message: "Unauthorized" 118 | }; 119 | return; 120 | } 121 | 122 | response.status = 200; 123 | response.body = user; 124 | } catch (error) { 125 | throw error; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import { PostgresClient } from "../deps.ts"; 2 | import { config } from "./config.ts"; 3 | 4 | export const dbClient = new PostgresClient(config.dbConfig); 5 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { makeJwt, Jose, Payload } from "../deps.ts"; 2 | import { config } from "./config.ts"; 3 | 4 | export function generateJwt(id: string) { 5 | const key = config.jwtSecret; 6 | 7 | const payload: Payload = { 8 | id 9 | }; 10 | 11 | const header: Jose = { 12 | alg: "HS256", 13 | typ: "JWT" 14 | }; 15 | 16 | return makeJwt({ header, payload, key }); 17 | } 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { bgGreen, black, Application, Router } from "../deps.ts"; 2 | import { hello, signup, login, me } from "./controllers/user.ts"; 3 | import { 4 | createPost, 5 | getPosts, 6 | getPost, 7 | deletePost, 8 | } from "./controllers/post.ts"; 9 | import { handleAuthHeader, handleErrors } from "./middlewares.ts"; 10 | import { IUser } from "./types.ts"; 11 | 12 | const router = new Router(); 13 | 14 | router 15 | .get("/", hello) 16 | .post("/signup", signup) 17 | .post("/login", login) 18 | .get("/me", me) 19 | .get("/posts", getPosts) 20 | .post("/posts", createPost) 21 | .get("/posts/:id", getPost) 22 | .delete("/posts/:id", deletePost); 23 | 24 | const app = new Application<{ 25 | user: Omit | null; 26 | }>(); 27 | 28 | app.use(handleAuthHeader); 29 | app.use(handleErrors); 30 | 31 | app.use(router.routes()); 32 | app.use(router.allowedMethods()); 33 | 34 | console.log(bgGreen(black("Server started on port 8000"))); 35 | 36 | await app.listen({ port: 8000 }); 37 | -------------------------------------------------------------------------------- /src/middlewares.ts: -------------------------------------------------------------------------------- 1 | import { Context, validateJwt } from "../deps.ts"; 2 | import { IUser } from "./types.ts"; 3 | import { config } from "./config.ts"; 4 | import { User } from "./models/user.ts"; 5 | 6 | export async function handleAuthHeader( 7 | ctx: Context<{ user: Omit | null }>, 8 | next: () => Promise 9 | ) { 10 | try { 11 | const { request, state } = ctx; 12 | 13 | const jwt = 14 | request.headers.get("authorization")?.split("bearer ")?.[1] || ""; 15 | 16 | const validatedJwt = await validateJwt(jwt, config.jwtSecret, { 17 | isThrowing: false 18 | }); 19 | 20 | if (!validatedJwt) { 21 | state.user = null; 22 | } 23 | 24 | const user = await User.findOneById(validatedJwt?.payload?.id! as string); 25 | if (!user) { 26 | state.user = null; 27 | } 28 | 29 | state.user = user; 30 | await next(); 31 | } catch (error) { 32 | throw error; 33 | } 34 | } 35 | 36 | export async function handleErrors( 37 | context: Context, 38 | next: () => Promise 39 | ) { 40 | try { 41 | await next(); 42 | } catch (err) { 43 | context.response.status = err.status; 44 | const { message = "unknown error", status = 500, stack = null } = err; 45 | context.response.body = { message, status, stack }; 46 | context.response.type = "json"; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/models/post.ts: -------------------------------------------------------------------------------- 1 | import { IPost, IUser } from "../types.ts"; 2 | import { PostgresClient } from "../../deps.ts"; 3 | import { dbClient } from "../db.ts"; 4 | 5 | class PostModel { 6 | private dbClient: PostgresClient; 7 | 8 | constructor(dbClient: PostgresClient) { 9 | this.dbClient = dbClient; 10 | } 11 | 12 | async insert( 13 | data: Omit & { userId: string } 14 | ): Promise | null> { 15 | try { 16 | await this.dbClient.connect(); 17 | const text = 18 | "insert into posts (id, title, body, user_id) values ($1, $2, $3, $4) returning id, title, body"; 19 | const result = await this.dbClient.query({ 20 | text, 21 | args: [data.id, data.title, data.body, data.userId] 22 | }); 23 | await this.dbClient.end(); 24 | 25 | return result.rowsOfObjects()[0] as Omit; 26 | } catch (error) { 27 | throw error; 28 | } 29 | } 30 | 31 | async delete(id: string): Promise { 32 | try { 33 | await this.dbClient.connect(); 34 | const text = "delete from posts where posts.id = $1"; 35 | await this.dbClient.query({ 36 | text, 37 | args: [id] 38 | }); 39 | await this.dbClient.end(); 40 | return true; 41 | } catch (error) { 42 | throw error; 43 | } 44 | } 45 | 46 | async findAll(): Promise[]> { 47 | try { 48 | await this.dbClient.connect(); 49 | const text = ` 50 | select posts.id as id, title, body, user_id, users.name as user_name 51 | from posts 52 | inner join users on users.id = posts.user_id;`; 53 | const result = await this.dbClient.query({ 54 | text 55 | }); 56 | await this.dbClient.end(); 57 | return result.rowsOfObjects().map(({ user_id, user_name, ...rest }) => ({ 58 | ...rest, 59 | user: { 60 | id: user_id as string, 61 | name: user_name as string 62 | } 63 | })); 64 | } catch (error) { 65 | throw error; 66 | } 67 | } 68 | 69 | async findOneById(id: string): Promise> { 70 | try { 71 | await this.dbClient.connect(); 72 | const text = ` 73 | select posts.id as id, title, body, user_id, users.name as user_name 74 | from posts 75 | inner join users on users.id = posts.user_id 76 | where posts.id = $1;`; 77 | const result = await this.dbClient.query({ 78 | text, 79 | args: [id] 80 | }); 81 | await this.dbClient.end(); 82 | return result.rowsOfObjects().map(({ user_id, user_name, ...rest }) => ({ 83 | ...rest, 84 | user: { 85 | id: user_id as string, 86 | name: user_name as string 87 | } 88 | }))[0]; 89 | } catch (error) { 90 | throw error; 91 | } 92 | } 93 | } 94 | 95 | export const Post = new PostModel(dbClient); 96 | -------------------------------------------------------------------------------- /src/models/user.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from "../types.ts"; 2 | import { PostgresClient, bcrypt } from "../../deps.ts"; 3 | import { dbClient } from "../db.ts"; 4 | 5 | class UserModel { 6 | private dbClient: PostgresClient; 7 | 8 | constructor(dbClient: PostgresClient) { 9 | this.dbClient = dbClient; 10 | } 11 | 12 | private async hashThePassowrd(password: string): Promise { 13 | return await bcrypt.hash(password); 14 | } 15 | 16 | private async beforeInsert(data: IUser): Promise { 17 | const hashedPassword = await this.hashThePassowrd(data.password); 18 | return { 19 | ...data, 20 | password: hashedPassword 21 | }; 22 | } 23 | 24 | async insert(args: IUser): Promise<{ id: string }> { 25 | try { 26 | await this.dbClient.connect(); 27 | const data = await this.beforeInsert(args); 28 | const text = 29 | "insert into users (id, email, password, name) values ($1, $2, $3, $4) returning id"; 30 | const result = await this.dbClient.query({ 31 | text, 32 | args: [data.id, data.email, data.password, data.name] 33 | }); 34 | await this.dbClient.end(); 35 | return { id: result.rows[0][0] }; 36 | } catch (error) { 37 | throw error; 38 | } 39 | } 40 | 41 | private async get(type: string, value: string | number): Promise { 42 | try { 43 | await this.dbClient.connect(); 44 | const text = `select * from users where ${type} = $1`; 45 | const result = await this.dbClient.query({ 46 | text, 47 | args: [value] 48 | }); 49 | await this.dbClient.end(); 50 | return result.rowsOfObjects() as IUser[]; 51 | } catch (error) { 52 | throw error; 53 | } 54 | } 55 | 56 | async findOneByEmail(email: string): Promise | null> { 57 | try { 58 | const [result] = await this.get("email", email); 59 | if (!result) return null; 60 | 61 | return { 62 | id: result.id, 63 | email: result.email, 64 | name: result.name 65 | }; 66 | } catch (error) { 67 | throw error; 68 | } 69 | } 70 | 71 | async findOneById(id: string): Promise | null> { 72 | try { 73 | const [result] = await this.get("id", id); 74 | if (!result) return null; 75 | 76 | return { 77 | id: result.id, 78 | email: result.email, 79 | name: result.name 80 | }; 81 | } catch (error) { 82 | throw error; 83 | } 84 | } 85 | 86 | async comparePassword( 87 | email: string, 88 | password: string 89 | ): Promise | null> { 90 | try { 91 | const [user] = await this.get("email", email); 92 | if (!user) return null; 93 | 94 | const result = bcrypt.compare(password, user.password); 95 | if (!result) return null; 96 | 97 | return { 98 | id: user.id, 99 | email: user.email, 100 | name: user.name 101 | }; 102 | } catch (error) { 103 | throw error; 104 | } 105 | } 106 | } 107 | 108 | export const User = new UserModel(dbClient); 109 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionOptions } from "../deps.ts"; 2 | 3 | export interface Config { 4 | dbConfig: ConnectionOptions; 5 | jwtSecret: string; 6 | } 7 | 8 | export interface IUser { 9 | id: string; 10 | password: string; 11 | name: string; 12 | email: string; 13 | } 14 | 15 | export interface IPost { 16 | id: string; 17 | title: string; 18 | body: string; 19 | user: Partial>; 20 | } 21 | --------------------------------------------------------------------------------