├── .gitignore ├── .prettierrc ├── Procfile ├── README.md ├── ormconfig.json ├── package-lock.json ├── package.json ├── src ├── config │ └── config.ts ├── controllers │ ├── AuthController.ts │ ├── CommentController.ts │ ├── PostController.ts │ └── UserController.ts ├── entity │ ├── Comment.ts │ ├── Post.ts │ └── User.ts ├── middlewares │ ├── checkIsAuthor.ts │ ├── checkIsCommenter.ts │ ├── checkIsUser.ts │ └── checkJwt.ts ├── routes │ ├── auth.ts │ ├── comment.ts │ ├── index.ts │ ├── post.ts │ └── user.ts └── server.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | ormconfig.json 4 | .env 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node dist/server.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## **HACKER NEWS API CLONE USING TYPEORM** 2 | 3 | ========================================================================== 4 | 5 | **Project Brief** 6 | 7 | Here is the challenge: 8 | 9 | > The goal of the project is to build a simple Hacker News API clone. If you don’t know Hacker News, it’s a very simple website where anyone can submit a link of a short message to discuss a specific topic with the dev community. 10 | > 11 | > You need to build an API that will allow a frontend developer to build the following pages: 12 | > 13 | > - https://news.ycombinator.com/ (no need to consider points and pagination) 14 | > - https://news.ycombinator.com/item?id=1925249 (including Add Comment feature, no need for points and nested / replies comments) 15 | > - https://news.ycombinator.com/submit (including Login and Signup form, no need for reset password) 16 | > 17 | > Step 1: Defining the API architecture (a list of endpoints if you choose to build a REST API or a list of queries / mutations if you choose to build a GraphQL API). You’ll need to justify the architecture. Feel free to do 2/3 versions of the architecture and argue why is one better than the others. 18 | > 19 | > Step 2: Choice of technology. You have to use the following technologies: Typescript, NodeJS, TypeORM with a SQL database (SQLite or MySQL or PostgreSQL). If you choose to use external libraries, you’ll need to justify your choices (Is it something you can implement by yourself? Why this specific library? etc…) 20 | > 21 | > Step 3: Build the API locally (test it with your browser or with Postman or equivalent) 22 | > 23 | > Step 4: Push the code to a public git repository (using Github or equivalent) 24 | > 25 | > Bonus steps (no specific order): 26 | > 27 | > 1. Write tests (you can use a TDD approach or write them afterwards) 28 | > 2. Deploy / host the API somewhere (Heroku and Zeit Now are good options for a simple project like this, but feel free to use something else if you want) 29 | > 3. Use Prettier to format the codebase 30 | > 4. Use ESLint with https://www.npmjs.com/package/eslint-config-airbnb-base 31 | 32 | ========================================================================== 33 | 34 | **Project Architecture** 35 | 36 | I created three Entities: User, Post and Comment. 37 | 38 | Each Entity has either a ManyToOne or OneToMany relationship with each of the other two (for example. a User can create many Posts, whereas a Comment can belong to only one Post.) 39 | 40 | I decided to create a REST API as that is what I'm familiar with. This is quite straightforward, with endpoints starting with /users, /posts and /comments respectively with which to carry out CRUD functions. 41 | 42 | (There is a fourth for Authentication, /auth, with two POST endpoints that take passwords, for logging in and changing the password. Changing the password was not in the scope of the brief, but the tutorial I used taught it (see 'Resources' section below), so I just included it.) 43 | 44 | For the GET endpoints, I tried to return as much data as possible as I thought it would be helpful for the Frontend developer. So, when you GET /users/:id, you get the User as well as all the User's Posts and Comments. When you GET /posts/:id, you get the Post and the User who wrote the Post (but not the Comments -- I will explain soon below). When you GET /comments/:id, you get the Comment, the Post the Comment belongs to, and the User who wrote the Comment. 45 | 46 | Of course, maybe returning so much data is too overwhelming if there are a lot of entries, so I might choose to leave out some data if this API were to be used on a large scale. 47 | 48 | As for challenges, my main challenge was in POSTing a Comment, as I need the Post's id to link the Comment to it (something I could not obtain by POSTing straight to /comments). In the end, I created /posts/:id/comments so I could access the Post's id via req.params. 49 | 50 | As it turned out, the /posts/:id/comments endpoint was also useful for GETting the Comments for a Post. If I tried to include a Post's Comments in the body returned from GETting /posts/:id, the Comment's User field would not be populated, because I don't know how to use TypeORM's Query Builder to 'join' an Entity two layers deep (Post>Comment>User). It is not very useful to have a Comment's data without knowing which User wrote the Comment, so in the end this endpoint really proved its utility (letting me just join Comment>User). 51 | 52 | I also had to protect certain routes with authorisation, following Hacker News's own logic (you don't have to log in to read posts and comments, but you must log in to create posts and comments). And Users should only be able to edit/delete their own Posts/Comments, as well as their own User details. 53 | 54 | TypeORM's onDelete: CASCADE was also useful to ensure that when an Entity instance is deleted, dependent Entity instances also are. For example, when a User is deleted, so are the Posts and Comments the User made. If a Post is deleted, so are the Comments on that Post. 55 | 56 | Lastly, I made the User password field a Hidden Column so that by default it does not return in API calls, and used addSelect to add the password only when necessary, i.e, when the User data is retrieved during Authentication. 57 | 58 | Below are the three Entitities, their relationships and endpoints. 59 | 60 | **USER** 61 | 62 | Relationships 63 | 64 | - OneToMany with Post 65 | - OneToMany with Comment 66 | 67 | Endpoints 68 | 69 | - GET /users 70 | - GET /users/:id 71 | - POST /users 72 | - PATCH /users/:id (needs authorisation with checkJwt and checkIsUser middlewares) 73 | - DELETE /users/:id (needs authorisation with checkJwt and checkIsUser middlewares) 74 | 75 | **POST** 76 | 77 | Relationships 78 | 79 | - ManyToOne with User 80 | - OneToMany with Comment 81 | 82 | Endpoints 83 | 84 | - GET /posts 85 | - GET /posts/:id 86 | - POST /posts (needs authorisation with checkJwt middleware) 87 | - PATCH /posts/:id (needs authorisation with checkJwt and checkIsAuthor middlewares) 88 | - DELETE /posts/:id (needs authorisation with checkJwt and checkIsAuthor middlewares) 89 | - GET /posts/:id/comments 90 | - POST /posts/:id/comments (needs authorisation with checkJwt middleware) 91 | 92 | **COMMENT** 93 | 94 | Relationships 95 | 96 | - ManyToOne with User 97 | - ManyToOne with Post 98 | 99 | Endpoints 100 | 101 | - GET /comments 102 | - GET /comments/:id 103 | - PATCH /comments/:id (needs authorisation with checkJwt and checkIsCommenter middlewares) 104 | - DELETE /comments/:id (needs authorisation with checkJwt and checkIsCommenter middlewares) 105 | 106 | Lastly, two endpoints for **Authentication**: logging in and changing the password. 107 | 108 | - POST /auth/login 109 | - POST /auth/change-password 110 | 111 | ========================================================================== 112 | 113 | **Resources** 114 | 115 | Created using hello-nodejs-typescript starter code: https://github.com/larkintuckerllc/hello-nodejs-typescript 116 | 117 | Started out with the help of this blog post: 118 | https://codeburst.io/typescript-node-starter-simplified-60c7b7d99e27 119 | 120 | And this series: 121 | https://codeburst.io/typeorm-by-example-part-1-6d6da04f9f23 122 | 123 | Then moved on to authentication with JWT with the help of: 124 | https://medium.com/javascript-in-plain-english/creating-a-rest-api-with-jwt-authentication-and-role-based-authorization-using-typescript-fbfa3cab22a4 125 | 126 | Also had to refer to TypeORM documentation often, especially for Query Builder: 127 | https://typeorm.io/ 128 | 129 | ========================================================================== 130 | 131 | **Tools** 132 | 133 | Database is Postgres.app: https://postgresapp.com/ 134 | 135 | - Tried to use Docker but it was too slow on my computer/wifi, while I had some difficulties when trying to use a downloaded PostgreSQL system. 136 | 137 | Heroku app is at https://typeorm-api-project.herokuapp.com/ 138 | 139 | - I always deployed to Heroku after succeeding locally so that I could tackle any difficulties step-by-step rather than accumulating them. 140 | 141 | Postman for API development environment 142 | 143 | ========================================================================== 144 | 145 | **Running the project** 146 | 147 | npm run watch-ts (recompiles application on source changes) 148 | 149 | npm run watch-node (restarts application on recompilation) 150 | 151 | npm run build-ts (only compiles the application) 152 | 153 | npm run serve/npm run start (only starts the application) 154 | 155 | ========================================================================== 156 | 157 | **Contact** 158 | 159 | https://github.com/stephanieye 160 | -------------------------------------------------------------------------------- /ormconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "postgres", 3 | "host": "localhost", 4 | "port": 5432, 5 | "synchronize": true, 6 | "logging": false, 7 | "entities": [ 8 | "dist/entity/**/*.js" 9 | ], 10 | "migrations": [ 11 | "dist/migration/**/*.js" 12 | ], 13 | "subscribers": [ 14 | "dist/subscriber/**/*.js" 15 | ], 16 | "cli": { 17 | "entitiesDir": "src/entity", 18 | "migrationsDir": "src/migration", 19 | "subscribersDir": "src/subscriber" 20 | } 21 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "first-backend-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/server.js", 6 | "scripts": { 7 | "build-ts": "tsc", 8 | "postinstall": "npm run build-ts", 9 | "start": "npm run serve", 10 | "serve": "node dist/server.js", 11 | "watch-node": "nodemon dist/server.js", 12 | "watch-ts": "tsc -w", 13 | "tslint": "./node_modules/.bin/tslint --format stylish --config ./tslint.json 'src/**/*.ts'", 14 | "tslint:fix": "npm run tslint -- --fix" 15 | }, 16 | "author": "", 17 | "license": "ISC", 18 | "devDependencies": { 19 | "husky": "^1.3.1", 20 | "nodemon": "^1.18.10", 21 | "prettier": "1.16.4", 22 | "pretty-quick": "^1.10.0", 23 | "tslint": "^5.13.0", 24 | "typescript": "^3.3.3333" 25 | }, 26 | "dependencies": { 27 | "@types/bcryptjs": "^2.4.2", 28 | "@types/body-parser": "^1.17.0", 29 | "@types/cors": "^2.8.4", 30 | "@types/dotenv": "^6.1.0", 31 | "@types/express": "^4.16.1", 32 | "@types/helmet": "0.0.42", 33 | "@types/jsonwebtoken": "^8.3.0", 34 | "@types/pg": "^7.4.13", 35 | "bcryptjs": "^2.4.3", 36 | "class-validator": "^0.9.1", 37 | "cors": "^2.8.5", 38 | "dotenv": "^6.2.0", 39 | "express": "^4.16.4", 40 | "helmet": "^3.15.1", 41 | "jsonwebtoken": "^8.5.0", 42 | "pg": "^7.8.1", 43 | "pg-connection-string": "^2.0.0", 44 | "reflect-metadata": "^0.1.13", 45 | "ts-node-dev": "^1.0.0-pre.32", 46 | "tslint-config-airbnb": "^5.11.1", 47 | "tslint-config-prettier": "^1.18.0", 48 | "tslint-plugin-prettier": "^2.0.1", 49 | "typeorm": "^0.2.14" 50 | }, 51 | "engines": { 52 | "node": "8.11.4" 53 | }, 54 | "husky": { 55 | "hooks": { 56 | "pre-commit": "pretty-quick --staged" 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | 3 | dotenv.config(); 4 | 5 | export default { 6 | jwtSecret: process.env.SECRET, 7 | }; 8 | -------------------------------------------------------------------------------- /src/controllers/AuthController.ts: -------------------------------------------------------------------------------- 1 | import { validate } from 'class-validator'; 2 | import { Request, Response } from 'express'; 3 | import * as jwt from 'jsonwebtoken'; 4 | import { getRepository } from 'typeorm'; 5 | import config from '../config/config'; 6 | import { User } from '../entity/User'; 7 | 8 | class AuthController { 9 | public static login = async (req: Request, res: Response) => { 10 | const { username, password } = req.body; 11 | if (!(username && password)) { 12 | res.status(400).send(); 13 | } 14 | const userRepository = getRepository(User); 15 | let user: User; 16 | try { 17 | user = await userRepository 18 | .createQueryBuilder('user') 19 | .addSelect('user.password') 20 | .where('user.username = :username', { username }) 21 | .getOne(); 22 | } catch (error) { 23 | res.status(401).send(); 24 | } 25 | if (!user.checkIfUnencryptedPasswordIsValid(password)) { 26 | res.status(401).send(); 27 | return; 28 | } 29 | const token = jwt.sign({ userId: user.id, username: user.username }, config.jwtSecret, { 30 | expiresIn: '1h', 31 | }); 32 | res.send(token); 33 | }; 34 | 35 | public static changePassword = async (req: Request, res: Response) => { 36 | const id = res.locals.jwtPayload.userId; 37 | const { oldPassword, newPassword } = req.body; 38 | if (!(oldPassword && newPassword)) { 39 | res.status(400).send(); 40 | } 41 | const userRepository = getRepository(User); 42 | let user: User; 43 | try { 44 | user = await userRepository 45 | .createQueryBuilder('user') 46 | .addSelect('user.password') 47 | .where('user.id = :id', { id }) 48 | .getOne(); 49 | } catch (id) { 50 | res.status(401).send(); 51 | } 52 | if (!user.checkIfUnencryptedPasswordIsValid(oldPassword)) { 53 | res.status(401).send(); 54 | return; 55 | } 56 | user.password = newPassword; 57 | const errors = await validate(user); 58 | if (errors.length > 0) { 59 | res.status(400).send(errors); 60 | return; 61 | } 62 | user.hashPassword(); 63 | userRepository.save(user); 64 | res.status(200).send('Password changed successfully 🥂'); 65 | }; 66 | } 67 | 68 | export default AuthController; 69 | -------------------------------------------------------------------------------- /src/controllers/CommentController.ts: -------------------------------------------------------------------------------- 1 | import { validate } from 'class-validator'; 2 | import { Request, Response } from 'express'; 3 | import { getRepository } from 'typeorm'; 4 | import { Comment } from '../entity/Comment'; 5 | 6 | class CommentController { 7 | public static listAll = async (req: Request, res: Response) => { 8 | const commentRepository = getRepository(Comment); 9 | const comments = await commentRepository.find(); 10 | res.send(comments); 11 | }; 12 | 13 | public static getOneById = async (req: Request, res: Response) => { 14 | const id: number = req.params.id; 15 | const commentRepository = getRepository(Comment); 16 | try { 17 | const comment = await commentRepository.findOneOrFail(id); 18 | res.send(comment); 19 | } catch (error) { 20 | res.status(404).send('Comment not found'); 21 | } 22 | }; 23 | 24 | public static editComment = async (req: Request, res: Response) => { 25 | const id = req.params.id; 26 | const { text } = req.body; 27 | const commentRepository = getRepository(Comment); 28 | let comment; 29 | try { 30 | comment = await commentRepository.findOneOrFail(id); 31 | } catch (error) { 32 | res.status(404).send('Comment not found'); 33 | return; 34 | } 35 | comment.text = text; 36 | const errors = await validate(comment); 37 | if (errors.length > 0) { 38 | res.status(400).send(errors); 39 | return; 40 | } 41 | try { 42 | await commentRepository.save(comment); 43 | } catch (e) { 44 | res.status(500).send('Sorry, something went wrong 😿'); 45 | return; 46 | } 47 | res.status(200).send('Comment edited'); 48 | }; 49 | 50 | public static deleteComment = async (req: Request, res: Response) => { 51 | const id = req.params.id; 52 | const commentRepository = getRepository(Comment); 53 | let comment: Comment; 54 | try { 55 | comment = await commentRepository.findOneOrFail(id); 56 | } catch (error) { 57 | res.status(404).send('Sorry, comment does not exist in the first place 😿'); 58 | return; 59 | } 60 | commentRepository.delete(id); 61 | res.status(200).send('Comment deleted'); 62 | }; 63 | } 64 | 65 | export default CommentController; 66 | -------------------------------------------------------------------------------- /src/controllers/PostController.ts: -------------------------------------------------------------------------------- 1 | import { validate } from 'class-validator'; 2 | import { Request, Response } from 'express'; 3 | import { getRepository } from 'typeorm'; 4 | import { Comment } from '../entity/Comment'; 5 | import { Post } from '../entity/Post'; 6 | import { User } from '../entity/User'; 7 | 8 | class PostController { 9 | public static listAll = async (req: Request, res: Response) => { 10 | const postRepository = getRepository(Post); 11 | const posts = await postRepository.find(); 12 | res.send(posts); 13 | }; 14 | 15 | public static getOneById = async (req: Request, res: Response) => { 16 | const id: number = req.params.id; 17 | const postRepository = getRepository(Post); 18 | try { 19 | const post = await postRepository.findOneOrFail(id); 20 | res.send(post); 21 | } catch (error) { 22 | res.status(404).send('Post not found'); 23 | } 24 | }; 25 | 26 | public static newPost = async (req: Request, res: Response) => { 27 | const currentUserId = res.locals.jwtPayload.userId; 28 | const userRepository = getRepository(User); 29 | let user; 30 | try { 31 | user = await userRepository.findOneOrFail(currentUserId); 32 | } catch (error) { 33 | res.status(404).send('User not found'); 34 | return; 35 | } 36 | const { title, url, text } = req.body; 37 | if (url && text) { 38 | res.status(400).send('Sorry, you can either post a url or text, not both! 😾'); 39 | return; 40 | } 41 | const post = new Post(); 42 | post.title = title; 43 | post.url = url || ''; 44 | post.text = text || ''; 45 | post.user = user; 46 | const errors = await validate(post); 47 | if (errors.length > 0) { 48 | res.status(400).send(errors); 49 | return; 50 | } 51 | const postRepository = getRepository(Post); 52 | try { 53 | await postRepository.save(post); 54 | } catch (e) { 55 | console.log(e); 56 | res.status(500).send('Sorry, something went wrong 😿'); 57 | return; 58 | } 59 | res.status(201).send('Post created'); 60 | }; 61 | 62 | public static editPost = async (req: Request, res: Response) => { 63 | const id = req.params.id; 64 | const { title, url, text } = req.body; 65 | if (url && text) { 66 | res.status(400).send('Sorry, you can either post a url or text, not both! 😾'); 67 | return; 68 | } 69 | const postRepository = getRepository(Post); 70 | let post; 71 | try { 72 | post = await postRepository.findOneOrFail(id); 73 | } catch (error) { 74 | res.status(404).send('Post not found'); 75 | return; 76 | } 77 | post.title = title; 78 | post.url = url || ''; 79 | post.text = text || ''; 80 | const errors = await validate(post); 81 | if (errors.length > 0) { 82 | res.status(400).send(errors); 83 | return; 84 | } 85 | try { 86 | await postRepository.save(post); 87 | } catch (e) { 88 | res.status(500).send('Sorry, something went wrong 😿'); 89 | return; 90 | } 91 | res.status(200).send('Post edited'); 92 | }; 93 | 94 | public static deletePost = async (req: Request, res: Response) => { 95 | const id = req.params.id; 96 | const postRepository = getRepository(Post); 97 | let post: Post; 98 | try { 99 | post = await postRepository.findOneOrFail(id); 100 | } catch (error) { 101 | res.status(404).send('Sorry, post does not exist in the first place 😿'); 102 | return; 103 | } 104 | postRepository.delete(id); 105 | res.status(200).send('Post deleted'); 106 | }; 107 | 108 | public static listAllComments = async (req: Request, res: Response) => { 109 | const id: number = req.params.id; 110 | const commentRepository = getRepository(Comment); 111 | try { 112 | const comments = await commentRepository 113 | .createQueryBuilder('comment') 114 | .leftJoinAndSelect('comment.user', 'user') 115 | .where('comment.post.id = :id', { id }) 116 | .getMany(); 117 | res.send(comments); 118 | } catch (error) { 119 | res.status(404).send('Comments not found for this post'); 120 | } 121 | }; 122 | 123 | public static newComment = async (req: Request, res: Response) => { 124 | const currentPostId = req.params.id; 125 | const currentUserId = res.locals.jwtPayload.userId; 126 | const postRepository = getRepository(Post); 127 | let post; 128 | try { 129 | post = await postRepository.findOneOrFail(currentPostId); 130 | } catch (error) { 131 | res.status(404).send('Post not found'); 132 | return; 133 | } 134 | const userRepository = getRepository(User); 135 | let user; 136 | try { 137 | user = await userRepository.findOneOrFail(currentUserId); 138 | } catch (error) { 139 | res.status(404).send('User not found'); 140 | return; 141 | } 142 | const { text } = req.body; 143 | const comment = new Comment(); 144 | comment.text = text; 145 | comment.user = user; 146 | comment.post = post; 147 | const errors = await validate(comment); 148 | if (errors.length > 0) { 149 | res.status(400).send(errors); 150 | return; 151 | } 152 | const commentRepository = getRepository(Comment); 153 | try { 154 | await commentRepository.save(comment); 155 | } catch (e) { 156 | console.log(e); 157 | res.status(500).send('Sorry, something went wrong 😿'); 158 | return; 159 | } 160 | res.status(201).send('Comment created'); 161 | }; 162 | } 163 | 164 | export default PostController; 165 | -------------------------------------------------------------------------------- /src/controllers/UserController.ts: -------------------------------------------------------------------------------- 1 | import { validate } from 'class-validator'; 2 | import { Request, Response } from 'express'; 3 | import { createQueryBuilder, getRepository } from 'typeorm'; 4 | import { User } from '../entity/User'; 5 | 6 | class UserController { 7 | public static listAll = async (req: Request, res: Response) => { 8 | const userRepository = getRepository(User); 9 | try { 10 | const users = await userRepository 11 | .createQueryBuilder('user') 12 | .leftJoinAndSelect('user.posts', 'post') 13 | .leftJoinAndSelect('user.comments', 'comment') 14 | .getMany(); 15 | res.send(users); 16 | } catch (error) { 17 | res.status(404).send(); 18 | } 19 | }; 20 | 21 | public static getOneById = async (req: Request, res: Response) => { 22 | const id: number = req.params.id; 23 | const userRepository = getRepository(User); 24 | try { 25 | const user = await userRepository 26 | .createQueryBuilder('user') 27 | .leftJoinAndSelect('user.posts', 'post') 28 | .leftJoinAndSelect('user.comments', 'comment') 29 | .where('user.id = :id', { id }) 30 | .getOne(); 31 | if (user) res.send(user); 32 | else res.status(404).send('User not found'); 33 | } catch (error) { 34 | res.status(404).send('User not found'); 35 | } 36 | }; 37 | 38 | public static newUser = async (req: Request, res: Response) => { 39 | const { username, password } = req.body; 40 | const user = new User(); 41 | user.username = username; 42 | user.password = password; 43 | const errors = await validate(user); 44 | if (errors.length > 0) { 45 | res.status(400).send(errors); 46 | return; 47 | } 48 | user.hashPassword(); 49 | const userRepository = getRepository(User); 50 | try { 51 | await userRepository.save(user); 52 | } catch (e) { 53 | res.status(409).send('Sorry, this username already exists 😿'); 54 | return; 55 | } 56 | res.status(201).send('User created'); 57 | }; 58 | 59 | public static editUser = async (req: Request, res: Response) => { 60 | const id = req.params.id; 61 | const { username } = req.body; 62 | const userRepository = getRepository(User); 63 | let user; 64 | try { 65 | user = await userRepository.findOneOrFail(id); 66 | } catch (error) { 67 | res.status(404).send('User not found'); 68 | return; 69 | } 70 | user = { ...user, username: username }; 71 | const errors = await validate(user); 72 | if (errors.length > 0) { 73 | res.status(400).send(errors); 74 | return; 75 | } 76 | try { 77 | await userRepository.save(user); 78 | } catch (e) { 79 | res.status(409).send('Sorry, this username already exists 😿'); 80 | return; 81 | } 82 | res.status(200).send('User updated'); 83 | }; 84 | 85 | public static deleteUser = async (req: Request, res: Response) => { 86 | const id = req.params.id; 87 | const userRepository = getRepository(User); 88 | let user: User; 89 | try { 90 | user = await userRepository.findOneOrFail(id); 91 | } catch (error) { 92 | res.status(404).send('User not found'); 93 | return; 94 | } 95 | userRepository.delete(id); 96 | res.status(200).send('Goodbye!'); 97 | }; 98 | } 99 | 100 | export default UserController; 101 | -------------------------------------------------------------------------------- /src/entity/Comment.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | import { 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | ManyToOne, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | import Post from './Post'; 11 | import User from './User'; 12 | 13 | @Entity() 14 | export class Comment { 15 | @PrimaryGeneratedColumn() 16 | public id: number; 17 | 18 | @Column() 19 | @IsNotEmpty() 20 | public text: string; 21 | 22 | @ManyToOne(() => User, user => user.comments, { eager: true, onDelete: 'CASCADE' }) 23 | public user: User; 24 | 25 | @ManyToOne(() => Post, post => post.comments, { eager: true, onDelete: 'CASCADE' }) 26 | public post: Post; 27 | 28 | @Column() 29 | @CreateDateColumn() 30 | public createdAt: Date; 31 | 32 | @Column() 33 | @UpdateDateColumn() 34 | public updatedAt: Date; 35 | } 36 | 37 | export default Comment; 38 | -------------------------------------------------------------------------------- /src/entity/Post.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | import { 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | ManyToOne, 7 | OneToMany, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | } from 'typeorm'; 11 | import Comment from './Comment'; 12 | import User from './User'; 13 | 14 | @Entity() 15 | export class Post { 16 | @PrimaryGeneratedColumn() 17 | public id: number; 18 | 19 | @Column() 20 | @IsNotEmpty() 21 | public title: string; 22 | 23 | @Column() 24 | public url: string; 25 | 26 | @Column() 27 | public text: string; 28 | 29 | @ManyToOne(() => User, user => user.posts, { eager: true, onDelete: 'CASCADE' }) 30 | public user: User; 31 | 32 | @OneToMany(() => Comment, comment => comment.post) 33 | public comments: Comment[]; 34 | 35 | @Column() 36 | @CreateDateColumn() 37 | public createdAt: Date; 38 | 39 | @Column() 40 | @UpdateDateColumn() 41 | public updatedAt: Date; 42 | } 43 | 44 | export default Post; 45 | -------------------------------------------------------------------------------- /src/entity/User.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcryptjs'; 2 | import { IsNotEmpty, Length } from 'class-validator'; 3 | import { 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | OneToMany, 8 | PrimaryGeneratedColumn, 9 | Unique, 10 | UpdateDateColumn, 11 | } from 'typeorm'; 12 | import Comment from './Comment'; 13 | import Post from './Post'; 14 | 15 | @Entity() 16 | @Unique(['username']) 17 | export class User { 18 | @PrimaryGeneratedColumn() 19 | public id: number; 20 | 21 | @Column() 22 | @Length(4, 20) 23 | @IsNotEmpty() 24 | public username: string; 25 | 26 | @Column({ select: false }) 27 | @Length(4, 100) 28 | @IsNotEmpty() 29 | public password: string; 30 | 31 | @OneToMany(() => Post, post => post.user) 32 | public posts: Post[]; 33 | 34 | @OneToMany(() => Comment, comment => comment.user) 35 | public comments: Comment[]; 36 | 37 | @Column() 38 | @CreateDateColumn() 39 | public createdAt: Date; 40 | 41 | @Column() 42 | @UpdateDateColumn() 43 | public updatedAt: Date; 44 | 45 | public hashPassword() { 46 | this.password = bcrypt.hashSync(this.password, 8); 47 | } 48 | 49 | public checkIfUnencryptedPasswordIsValid(unencryptedPassword: string) { 50 | return bcrypt.compareSync(unencryptedPassword, this.password); 51 | } 52 | } 53 | 54 | export default User; 55 | -------------------------------------------------------------------------------- /src/middlewares/checkIsAuthor.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import { getRepository } from 'typeorm' 3 | import Post from '../entity/Post' 4 | 5 | export const checkIsAuthor = (req: Request, res: Response, next: NextFunction) => { 6 | const currentUserId = res.locals.jwtPayload.userId 7 | getRepository(Post) 8 | .findOne(req.params.id) 9 | .then((post) => { 10 | const authorId = post.user.id 11 | if (authorId === currentUserId) { 12 | next() 13 | } else { 14 | res.status(404).send('Sorry, you are not authorised to modify this post 😿') 15 | } 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /src/middlewares/checkIsCommenter.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import { getRepository } from 'typeorm' 3 | import Comment from '../entity/Comment' 4 | 5 | export const checkIsCommenter = (req: Request, res: Response, next: NextFunction) => { 6 | const currentUserId = res.locals.jwtPayload.userId 7 | getRepository(Comment) 8 | .findOne(req.params.id) 9 | .then((comment) => { 10 | const commenterId = comment.user.id 11 | if (commenterId === currentUserId) { 12 | next() 13 | } else { 14 | res.status(404).send('Sorry, you are not authorised to modify this comment 😿') 15 | } 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /src/middlewares/checkIsUser.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | 3 | export const checkIsUser = (req: Request, res: Response, next: NextFunction) => { 4 | const currentUserId = res.locals.jwtPayload.userId 5 | if (currentUserId === Number(req.params.id)) { 6 | next() 7 | } else { 8 | res.status(401).send() 9 | return 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/middlewares/checkJwt.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import * as jwt from 'jsonwebtoken' 3 | import config from '../config/config' 4 | 5 | export const checkJwt = (req: Request, res: Response, next: NextFunction) => { 6 | if (!req.headers.authorization) { 7 | res.status(401).send() 8 | return 9 | } 10 | 11 | const token = req.headers.authorization.split(' ')[1] as string 12 | let jwtPayload 13 | 14 | try { 15 | jwtPayload = jwt.verify(token, config.jwtSecret) as any 16 | res.locals.jwtPayload = jwtPayload 17 | } catch (error) { 18 | console.log(error) 19 | res.status(401).send() 20 | return 21 | } 22 | 23 | const { userId, username } = jwtPayload 24 | const newToken = jwt.sign({ userId, username }, config.jwtSecret, { 25 | expiresIn: '1h', 26 | }) 27 | res.setHeader('token', newToken) 28 | 29 | next() 30 | } 31 | -------------------------------------------------------------------------------- /src/routes/auth.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import AuthController from '../controllers/AuthController' 3 | import { checkIsUser } from '../middlewares/checkIsUser' 4 | import { checkJwt } from '../middlewares/checkJwt' 5 | 6 | const router = Router() 7 | 8 | router.post('/login', AuthController.login) 9 | 10 | router.post('/change-password', [checkJwt], AuthController.changePassword) 11 | 12 | export default router 13 | -------------------------------------------------------------------------------- /src/routes/comment.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import CommentController from '../controllers/CommentController' 3 | import { checkIsCommenter } from '../middlewares/checkIsCommenter' 4 | import { checkJwt } from '../middlewares/checkJwt' 5 | 6 | const router = Router() 7 | 8 | router.get('/', CommentController.listAll) 9 | 10 | router.get('/:id', CommentController.getOneById) 11 | 12 | router.patch('/:id', [checkJwt, checkIsCommenter], CommentController.editComment) 13 | 14 | router.delete('/:id', [checkJwt, checkIsCommenter], CommentController.deleteComment) 15 | 16 | export default router 17 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express' 2 | import auth from './auth' 3 | import comment from './comment' 4 | import post from './post' 5 | import user from './user' 6 | 7 | const routes = Router() 8 | 9 | routes.get('/', (req, res) => 10 | res.send( 11 | 'This is a basic Hacker News API clone using TypeScript, Node.js, TypeORM and PostgreSQL', 12 | ), 13 | ) 14 | 15 | routes.use('/auth', auth) 16 | routes.use('/users', user) 17 | routes.use('/posts', post) 18 | routes.use('/comments', comment) 19 | 20 | export default routes 21 | -------------------------------------------------------------------------------- /src/routes/post.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import PostController from '../controllers/PostController' 3 | import { checkIsAuthor } from '../middlewares/checkIsAuthor' 4 | import { checkJwt } from '../middlewares/checkJwt' 5 | 6 | const router = Router() 7 | 8 | router.get('/', PostController.listAll) 9 | 10 | router.get('/:id', PostController.getOneById) 11 | 12 | router.post('/', checkJwt, PostController.newPost) 13 | 14 | router.patch('/:id', [checkJwt, checkIsAuthor], PostController.editPost) 15 | 16 | router.delete('/:id', [checkJwt, checkIsAuthor], PostController.deletePost) 17 | 18 | router.get('/:id/comments', PostController.listAllComments) 19 | 20 | router.post('/:id/comments', [checkJwt], PostController.newComment) 21 | 22 | export default router 23 | -------------------------------------------------------------------------------- /src/routes/user.ts: -------------------------------------------------------------------------------- 1 | import { Response, Router } from 'express' 2 | import UserController from '../controllers/UserController' 3 | import { checkIsUser } from '../middlewares/checkIsUser' 4 | import { checkJwt } from '../middlewares/checkJwt' 5 | 6 | const router = Router() 7 | 8 | router.get('/', UserController.listAll) 9 | 10 | router.get('/:id', UserController.getOneById) 11 | 12 | router.post('/', UserController.newUser) 13 | 14 | router.patch('/:id', [checkJwt, checkIsUser], UserController.editUser) 15 | 16 | router.delete('/:id', [checkJwt, checkIsUser], UserController.deleteUser) 17 | 18 | export default router 19 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import 'reflect-metadata' 3 | import { createConnection } from 'typeorm' 4 | 5 | import bodyParser from 'body-parser' 6 | import cors from 'cors' 7 | import helmet from 'helmet' 8 | import PostgressConnectionStringParser from 'pg-connection-string' 9 | import routes from './routes' 10 | 11 | const port = process.env.PORT || 3000 12 | 13 | const databaseUrl: string = process.env.DATABASE_URL 14 | const typeOrmOptions: any = isHeroku() 15 | 16 | function isHeroku() { 17 | if (process.env.DATABASE_URL) { 18 | const connectionOptions = PostgressConnectionStringParser.parse(databaseUrl) 19 | return { 20 | type: 'postgres', 21 | host: connectionOptions.host, 22 | port: connectionOptions.port, 23 | username: connectionOptions.user, 24 | password: connectionOptions.password, 25 | database: connectionOptions.database, 26 | synchronize: true, 27 | entities: ['dist/entity/*.js'], 28 | extra: { 29 | ssl: true, 30 | }, 31 | } 32 | } 33 | return null 34 | } 35 | 36 | const connection = createConnection(typeOrmOptions) 37 | 38 | connection 39 | .then(async () => { 40 | const app = express() 41 | app.use(cors()) 42 | app.use(helmet()) 43 | app.use(bodyParser.json()) 44 | 45 | app.use('/', routes) 46 | 47 | app.listen(port, () => console.log(`I am listening on port ${port} 😸`)) 48 | }) 49 | .catch(error => console.log('Uh-oh 😿', error)) 50 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "es6", 6 | "noImplicitAny": true, 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "outDir": "dist", 10 | "baseUrl": ".", 11 | "paths": { 12 | "*": [ 13 | "node_modules/*", 14 | "src/types/*" 15 | ] 16 | }, 17 | "lib": [ 18 | "es2015" 19 | ], 20 | "emitDecoratorMetadata": true, 21 | "experimentalDecorators": true 22 | }, 23 | "include": [ 24 | "src/**/*" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-airbnb", 3 | "rules": { 4 | "semicolon": [ 5 | true, 6 | "never" 7 | ], 8 | "variable-name": [false], 9 | "max-line-length": { 10 | "options": [ 11 | 140 12 | ], 13 | "severity": "warning" 14 | }, 15 | 16 | "no-else-after-return": [ 17 | "error", 18 | { 19 | "allowElseIf": "true" 20 | } 21 | ] 22 | } 23 | } --------------------------------------------------------------------------------