├── .gitignore ├── NOTES.md ├── README.md ├── config ├── default.ts └── test.ts ├── diagrams └── data-flow-testing.png ├── jest.config.js ├── package.json ├── postman_collection.json ├── src ├── __tests__ │ ├── product.test.ts │ └── user.test.ts ├── app.ts ├── controller │ ├── product.controller.ts │ ├── session.controller.ts │ └── user.controller.ts ├── middleware │ ├── deserializeUser.ts │ ├── requireUser.ts │ └── validateResource.ts ├── models │ ├── product.model.ts │ ├── session.model.ts │ └── user.model.ts ├── routes.ts ├── schema │ ├── product.schema.ts │ ├── session.schema.ts │ └── user.schema.ts ├── service │ ├── product.service.ts │ ├── session.service.ts │ └── user.service.ts └── utils │ ├── connect.ts │ ├── jwt.utils.ts │ ├── logger.ts │ └── server.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | yarn add supertest jest ts-jest @types/jest @types/supertest -D 2 | 3 | yarn ts-jest config:init -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Testing Express REST API 2 | 3 | ## Key takeaways 4 | * An understanding of how important testing is 5 | * Testing doesn't have to be complicated or difficult 6 | 7 | ## What will you learn? 8 | * Testing a REST API end-to-end with Supertest & mongodb-memory-server 9 | * Mocking services 10 | * Testing from the controller to the service 11 | 12 | 13 | Read: https://github.com/goldbergyoni/javascript-testing-best-practices 14 | 15 | ## What you will need 16 | * Clone this repository: https://github.com/TomDoesTech/REST-API-Tutorial-Updated 17 | * An IDE or text editor (VS Code) 18 | * A package manager such as NPM or Yarn 19 | * Node.js installed 20 | 21 | 22 | ## What next? 23 | * ~~Testing the API with Jest~~ 24 | * Build a React.js user interface 25 | * Add Prometheus metrics to the API 26 | * Deploy the API with Caddy & Docker 27 | * Add Google OAuth 28 | 29 | ## Data flow 30 | ![](./diagrams/data-flow-testing.png) 31 | 32 | 33 | ## Let's keep in touch 34 | - [Subscribe on YouTube](https://www.youtube.com/TomDoesTech) 35 | - [Discord](https://discord.gg/4ae2Esm6P7) 36 | - [Twitter](https://twitter.com/tomdoes_tech) 37 | - [TikTok](https://www.tiktok.com/@tomdoestech) 38 | - [Facebook](https://www.facebook.com/tomdoestech) 39 | - [Instagram](https://www.instagram.com/tomdoestech) 40 | 41 | [Buy me a Coffee](https://www.buymeacoffee.com/tomn) 42 | 43 | [Sign up to DigitalOcean 💖](https://m.do.co/c/1b74cb8c56f4) 44 | -------------------------------------------------------------------------------- /config/default.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | port: 1337, 3 | dbUri: "mongodb://localhost:27017/rest-api-tutorial", 4 | saltWorkFactor: 10, 5 | accessTokenTtl: "15m", 6 | refreshTokenTtl: "1y", 7 | publicKey: `-----BEGIN PUBLIC KEY----- 8 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCXzZerpx9qdaelwt1U7NCpWXQK 9 | km1OW4ohDF/7g01xDtYf8Nox9wzhhVQrFD+G4eaJoWxIhJYQTgT4ijMlpjXs07Mc 10 | wktcMX49h6Eoo6ZddOMl380UpivkaO+h80miG4JCFAM0G0pUoeNT8h6L9zHqr/yE 11 | oBRd3RAsqxeCKwKrswIDAQAB 12 | -----END PUBLIC KEY-----`, 13 | privateKey: `-----BEGIN RSA PRIVATE KEY----- 14 | MIICXAIBAAKBgQCXzZerpx9qdaelwt1U7NCpWXQKkm1OW4ohDF/7g01xDtYf8Nox 15 | 9wzhhVQrFD+G4eaJoWxIhJYQTgT4ijMlpjXs07McwktcMX49h6Eoo6ZddOMl380U 16 | pivkaO+h80miG4JCFAM0G0pUoeNT8h6L9zHqr/yEoBRd3RAsqxeCKwKrswIDAQAB 17 | AoGASdYpcMewQzMJIVpgF7+8WrL0+3NvkU57KEoBoa+jClviENUw/F6jOEqGvxFx 18 | OOVGIimPJtK+vx2D03Q9HpHy5gfG3Q0hXq8RABfzOkgKOQJYEb/CtkadRo9sqPpw 19 | PtXaFrwYNPevqzUydk5Y4imQN0yycmKPirgWP2GGZj8nAxECQQDMmWI6BkE+8p0H 20 | PszrkpDlAo3p/4oMW4XgoxBbIuHMuzYZi6d6FUvcOjGI88ktTBSMIhwjF5K/lIBU 21 | RXcKjzKJAkEAvfCtHN6oLs22MXp1AZBTSIfLp/8C4nLfW6VMboR+Sw9R5MatTuTI 22 | W4seeBxn1/DU88IhU579maYMX8TFMITNWwJATAPMz9wVD6saFmAJyJhKxxsc2Mx9 23 | YLv8UIqlAAdEt0jy/6i4T45sAeWJE+XnX7H9jFb79znB5vXoe+bXJJAxaQJBAK9P 24 | WeigleiFFBl85kvoMwZp2A4Z8EakMgX7fp0vCwYqgLLAsat0vHzJ3fiMd0g3T8Yg 25 | svPkdjt/qEIocryChqsCQGnInFDk9fC5C8G1Ez2N/p0WztoPDNKfYVDGZ8IRMjEy 26 | rPeH03CIhIoh6QPPg02GKC3VumQxgwOe2wkQ2OgHYLE= 27 | -----END RSA PRIVATE KEY-----`, 28 | }; 29 | -------------------------------------------------------------------------------- /config/test.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | port: 1337, 3 | dbUri: "mongodb://localhost:27017/rest-api-tutorial", 4 | saltWorkFactor: 10, 5 | accessTokenTtl: "15m", 6 | refreshTokenTtl: "1y", 7 | publicKey: `-----BEGIN PUBLIC KEY----- 8 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCXzZerpx9qdaelwt1U7NCpWXQK 9 | km1OW4ohDF/7g01xDtYf8Nox9wzhhVQrFD+G4eaJoWxIhJYQTgT4ijMlpjXs07Mc 10 | wktcMX49h6Eoo6ZddOMl380UpivkaO+h80miG4JCFAM0G0pUoeNT8h6L9zHqr/yE 11 | oBRd3RAsqxeCKwKrswIDAQAB 12 | -----END PUBLIC KEY-----`, 13 | privateKey: `-----BEGIN RSA PRIVATE KEY----- 14 | MIICXAIBAAKBgQCXzZerpx9qdaelwt1U7NCpWXQKkm1OW4ohDF/7g01xDtYf8Nox 15 | 9wzhhVQrFD+G4eaJoWxIhJYQTgT4ijMlpjXs07McwktcMX49h6Eoo6ZddOMl380U 16 | pivkaO+h80miG4JCFAM0G0pUoeNT8h6L9zHqr/yEoBRd3RAsqxeCKwKrswIDAQAB 17 | AoGASdYpcMewQzMJIVpgF7+8WrL0+3NvkU57KEoBoa+jClviENUw/F6jOEqGvxFx 18 | OOVGIimPJtK+vx2D03Q9HpHy5gfG3Q0hXq8RABfzOkgKOQJYEb/CtkadRo9sqPpw 19 | PtXaFrwYNPevqzUydk5Y4imQN0yycmKPirgWP2GGZj8nAxECQQDMmWI6BkE+8p0H 20 | PszrkpDlAo3p/4oMW4XgoxBbIuHMuzYZi6d6FUvcOjGI88ktTBSMIhwjF5K/lIBU 21 | RXcKjzKJAkEAvfCtHN6oLs22MXp1AZBTSIfLp/8C4nLfW6VMboR+Sw9R5MatTuTI 22 | W4seeBxn1/DU88IhU579maYMX8TFMITNWwJATAPMz9wVD6saFmAJyJhKxxsc2Mx9 23 | YLv8UIqlAAdEt0jy/6i4T45sAeWJE+XnX7H9jFb79znB5vXoe+bXJJAxaQJBAK9P 24 | WeigleiFFBl85kvoMwZp2A4Z8EakMgX7fp0vCwYqgLLAsat0vHzJ3fiMd0g3T8Yg 25 | svPkdjt/qEIocryChqsCQGnInFDk9fC5C8G1Ez2N/p0WztoPDNKfYVDGZ8IRMjEy 26 | rPeH03CIhIoh6QPPg02GKC3VumQxgwOe2wkQ2OgHYLE= 27 | -----END RSA PRIVATE KEY-----`, 28 | }; 29 | -------------------------------------------------------------------------------- /diagrams/data-flow-testing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomDoesTech/Testing-Express-REST-API/3299d38a698821fcee725ba71f50c3dc1c09eb81/diagrams/data-flow-testing.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | testMatch: ["**/**/*.test.ts"], 6 | verbose: true, 7 | forceExit: true, 8 | clearMocks: true, 9 | resetMocks: true, 10 | restoreMocks: true, 11 | clearMocks:true, 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rest-api-tutorial-updated", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "Tom Nagle", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "ts-node-dev --respawn --transpile-only src/app.ts", 9 | "test": "jest" 10 | }, 11 | "dependencies": { 12 | "bcrypt": "^5.0.1", 13 | "config": "^3.3.6", 14 | "cors": "^2.8.5", 15 | "dayjs": "^1.10.7", 16 | "express": "^4.17.1", 17 | "jsonwebtoken": "^8.5.1", 18 | "lodash": "^4.17.21", 19 | "mongodb": "^5.4.0", 20 | "mongodb-memory-server": "^8.12.2", 21 | "mongoose": "^6.0.8", 22 | "nanoid": "^3.1.28", 23 | "pino": "^6.13.3", 24 | "pino-pretty": "^7.0.1", 25 | "zod": "^3.9.5" 26 | }, 27 | "devDependencies": { 28 | "@types/bcrypt": "^5.0.0", 29 | "@types/body-parser": "^1.19.1", 30 | "@types/config": "^0.0.39", 31 | "@types/cors": "^2.8.12", 32 | "@types/express": "^4.17.13", 33 | "@types/jest": "^27.0.2", 34 | "@types/jsonwebtoken": "^8.5.5", 35 | "@types/lodash": "^4.14.175", 36 | "@types/mongoose": "^5.11.97", 37 | "@types/nanoid": "^3.0.0", 38 | "@types/node": "^16.10.2", 39 | "@types/pino": "^6.3.11", 40 | "@types/supertest": "^2.0.11", 41 | "jest": "^27.2.4", 42 | "supertest": "^6.1.6", 43 | "ts-jest": "^27.0.5", 44 | "ts-node-dev": "^1.1.8", 45 | "typescript": "^4.4.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "e85c579e-200a-40b9-9ce3-26a6e6f85ebe", 4 | "name": "REST API Tutorial", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "User", 10 | "item": [ 11 | { 12 | "name": "Create User", 13 | "request": { 14 | "method": "POST", 15 | "header": [], 16 | "body": { 17 | "mode": "raw", 18 | "raw": "{\n \"email\": \"{{email}}\",\n \"password\": \"{{password}}\",\n \"passwordConfirmation\": \"{{password}}\",\n \"name\": \"{{name}}\"\n}", 19 | "options": { 20 | "raw": { 21 | "language": "json" 22 | } 23 | } 24 | }, 25 | "url": { 26 | "raw": "{{endpoint}}/api/users", 27 | "host": [ 28 | "{{endpoint}}" 29 | ], 30 | "path": [ 31 | "api", 32 | "users" 33 | ] 34 | } 35 | }, 36 | "response": [] 37 | }, 38 | { 39 | "name": "Create Session", 40 | "event": [ 41 | { 42 | "listen": "test", 43 | "script": { 44 | "exec": [ 45 | "var jsonData = JSON.parse(responseBody);", 46 | "postman.setEnvironmentVariable(\"accessToken\", jsonData.accessToken);", 47 | "postman.setEnvironmentVariable(\"refreshToken\", jsonData.refreshToken);" 48 | ], 49 | "type": "text/javascript" 50 | } 51 | } 52 | ], 53 | "request": { 54 | "method": "POST", 55 | "header": [], 56 | "body": { 57 | "mode": "raw", 58 | "raw": "{\n \"email\": \"{{email}}\",\n \"password\": \"{{password}}\"\n}", 59 | "options": { 60 | "raw": { 61 | "language": "json" 62 | } 63 | } 64 | }, 65 | "url": { 66 | "raw": "{{endpoint}}/api/sessions", 67 | "host": [ 68 | "{{endpoint}}" 69 | ], 70 | "path": [ 71 | "api", 72 | "sessions" 73 | ] 74 | } 75 | }, 76 | "response": [] 77 | }, 78 | { 79 | "name": "Get Sessions", 80 | "event": [ 81 | { 82 | "listen": "test", 83 | "script": { 84 | "exec": [ 85 | "", 86 | "const newAccessToken = responseHeaders['x-access-token']", 87 | "", 88 | "if(newAccessToken){", 89 | " console.log('Set new access token')", 90 | "postman.setEnvironmentVariable(\"accessToken\", newAccessToken);", 91 | "}", 92 | "", 93 | "" 94 | ], 95 | "type": "text/javascript" 96 | } 97 | } 98 | ], 99 | "request": { 100 | "auth": { 101 | "type": "bearer", 102 | "bearer": [ 103 | { 104 | "key": "token", 105 | "value": "{{accessToken}}", 106 | "type": "string" 107 | } 108 | ] 109 | }, 110 | "method": "GET", 111 | "header": [ 112 | { 113 | "key": "x-refresh", 114 | "value": "{{refreshToken}}", 115 | "type": "text" 116 | } 117 | ], 118 | "url": { 119 | "raw": "{{endpoint}}/api/sessions", 120 | "host": [ 121 | "{{endpoint}}" 122 | ], 123 | "path": [ 124 | "api", 125 | "sessions" 126 | ] 127 | } 128 | }, 129 | "response": [] 130 | }, 131 | { 132 | "name": "Delete Session", 133 | "event": [ 134 | { 135 | "listen": "test", 136 | "script": { 137 | "exec": [ 138 | "const newAccessToken = responseHeaders['x-access-token']", 139 | "", 140 | "if(newAccessToken){", 141 | " console.log('Set new access token')", 142 | "postman.setEnvironmentVariable(\"accessToken\", newAccessToken);", 143 | "}", 144 | "", 145 | "" 146 | ], 147 | "type": "text/javascript" 148 | } 149 | } 150 | ], 151 | "request": { 152 | "auth": { 153 | "type": "bearer", 154 | "bearer": [ 155 | { 156 | "key": "token", 157 | "value": "{{accessToken}}", 158 | "type": "string" 159 | } 160 | ] 161 | }, 162 | "method": "DELETE", 163 | "header": [ 164 | { 165 | "key": "x-refresh", 166 | "value": "{{refreshToken}}", 167 | "type": "text" 168 | } 169 | ], 170 | "url": { 171 | "raw": "{{endpoint}}/api/sessions", 172 | "host": [ 173 | "{{endpoint}}" 174 | ], 175 | "path": [ 176 | "api", 177 | "sessions" 178 | ] 179 | } 180 | }, 181 | "response": [] 182 | } 183 | ] 184 | }, 185 | { 186 | "name": "Product", 187 | "item": [ 188 | { 189 | "name": "Create Product", 190 | "event": [ 191 | { 192 | "listen": "test", 193 | "script": { 194 | "exec": [ 195 | "var jsonData = JSON.parse(responseBody);", 196 | "postman.setEnvironmentVariable(\"productId\", jsonData.productId);", 197 | "", 198 | "", 199 | "const newAccessToken = responseHeaders['x-access-token']", 200 | "", 201 | "if(newAccessToken){", 202 | " console.log('Set new access token')", 203 | "postman.setEnvironmentVariable(\"accessToken\", newAccessToken);", 204 | "}" 205 | ], 206 | "type": "text/javascript" 207 | } 208 | } 209 | ], 210 | "request": { 211 | "auth": { 212 | "type": "bearer", 213 | "bearer": [ 214 | { 215 | "key": "token", 216 | "value": "{{accessToken}}", 217 | "type": "string" 218 | } 219 | ] 220 | }, 221 | "method": "POST", 222 | "header": [ 223 | { 224 | "key": "x-refresh", 225 | "value": "{{refreshToken}}", 226 | "type": "text" 227 | } 228 | ], 229 | "body": { 230 | "mode": "raw", 231 | "raw": "{\n \"title\": \"Canon EOS 1500D DSLR Camera with 18-55mm Lens\",\n \"description\": \"Designed for first-time DSLR owners who want impressive results straight out of the box, capture those magic moments no matter your level with the EOS 1500D. With easy to use automatic shooting modes, large 24.1 MP sensor, Canon Camera Connect app integration and built-in feature guide, EOS 1500D is always ready to go.\",\n \"price\": 879.99,\n \"image\": \"https://i.imgur.com/QlRphfQ.jpg\"\n}", 232 | "options": { 233 | "raw": { 234 | "language": "json" 235 | } 236 | } 237 | }, 238 | "url": { 239 | "raw": "{{endpoint}}/api/products", 240 | "host": [ 241 | "{{endpoint}}" 242 | ], 243 | "path": [ 244 | "api", 245 | "products" 246 | ] 247 | } 248 | }, 249 | "response": [] 250 | }, 251 | { 252 | "name": "Get Product", 253 | "event": [ 254 | { 255 | "listen": "test", 256 | "script": { 257 | "exec": [ 258 | "" 259 | ], 260 | "type": "text/javascript" 261 | } 262 | } 263 | ], 264 | "protocolProfileBehavior": { 265 | "disableBodyPruning": true 266 | }, 267 | "request": { 268 | "method": "GET", 269 | "header": [], 270 | "body": { 271 | "mode": "raw", 272 | "raw": "{\n \"title\": \"A post title\",\n \"body\": \"Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text\"\n}", 273 | "options": { 274 | "raw": { 275 | "language": "json" 276 | } 277 | } 278 | }, 279 | "url": { 280 | "raw": "{{endpoint}}/api/products/{{productId}}", 281 | "host": [ 282 | "{{endpoint}}" 283 | ], 284 | "path": [ 285 | "api", 286 | "products", 287 | "{{productId}}" 288 | ] 289 | } 290 | }, 291 | "response": [] 292 | }, 293 | { 294 | "name": "Update Product", 295 | "event": [ 296 | { 297 | "listen": "test", 298 | "script": { 299 | "exec": [ 300 | "const newAccessToken = responseHeaders['x-access-token']", 301 | "", 302 | "if(newAccessToken){", 303 | " console.log('Set new access token')", 304 | "postman.setEnvironmentVariable(\"accessToken\", newAccessToken);", 305 | "}" 306 | ], 307 | "type": "text/javascript" 308 | } 309 | } 310 | ], 311 | "request": { 312 | "auth": { 313 | "type": "bearer", 314 | "bearer": [ 315 | { 316 | "key": "token", 317 | "value": "{{accessToken}}", 318 | "type": "string" 319 | } 320 | ] 321 | }, 322 | "method": "PUT", 323 | "header": [ 324 | { 325 | "key": "x-refresh", 326 | "value": "{{refreshToken}}", 327 | "type": "text" 328 | } 329 | ], 330 | "body": { 331 | "mode": "raw", 332 | "raw": "{\n \"title\": \"Canon EOS 1500D DSLR Camera with 18-55mm Lens\",\n \"description\": \"Designed for first-time DSLR owners who want impressive results straight out of the box, capture those magic moments no matter your level with the EOS 1500D. With easy to use automatic shooting modes, large 24.1 MP sensor, Canon Camera Connect app integration and built-in feature guide, EOS 1500D is always ready to go.\",\n \"price\": 699.99,\n \"image\": \"https://i.imgur.com/QlRphfQ.jpg\"\n}", 333 | "options": { 334 | "raw": { 335 | "language": "json" 336 | } 337 | } 338 | }, 339 | "url": { 340 | "raw": "{{endpoint}}/api/products/{{productId}}", 341 | "host": [ 342 | "{{endpoint}}" 343 | ], 344 | "path": [ 345 | "api", 346 | "products", 347 | "{{productId}}" 348 | ] 349 | } 350 | }, 351 | "response": [] 352 | }, 353 | { 354 | "name": "Delete Product", 355 | "event": [ 356 | { 357 | "listen": "test", 358 | "script": { 359 | "exec": [ 360 | "const newAccessToken = responseHeaders['x-access-token']", 361 | "", 362 | "if(newAccessToken){", 363 | " console.log('Set new access token')", 364 | "postman.setEnvironmentVariable(\"accessToken\", newAccessToken);", 365 | "}" 366 | ], 367 | "type": "text/javascript" 368 | } 369 | } 370 | ], 371 | "request": { 372 | "auth": { 373 | "type": "bearer", 374 | "bearer": [ 375 | { 376 | "key": "token", 377 | "value": "{{accessToken}}", 378 | "type": "string" 379 | } 380 | ] 381 | }, 382 | "method": "DELETE", 383 | "header": [ 384 | { 385 | "key": "x-refresh", 386 | "value": "{{refreshToken}}", 387 | "type": "text" 388 | } 389 | ], 390 | "body": { 391 | "mode": "raw", 392 | "raw": "{\n \"title\": \"A post title\",\n \"body\": \"Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text Some HTML text\"\n}", 393 | "options": { 394 | "raw": { 395 | "language": "json" 396 | } 397 | } 398 | }, 399 | "url": { 400 | "raw": "{{endpoint}}/api/products/{{productId}}", 401 | "host": [ 402 | "{{endpoint}}" 403 | ], 404 | "path": [ 405 | "api", 406 | "products", 407 | "{{productId}}" 408 | ] 409 | } 410 | }, 411 | "response": [] 412 | } 413 | ] 414 | } 415 | ], 416 | "auth": { 417 | "type": "bearer", 418 | "bearer": [ 419 | { 420 | "key": "token", 421 | "value": "{{accessToken}}", 422 | "type": "string" 423 | } 424 | ] 425 | }, 426 | "event": [ 427 | { 428 | "listen": "prerequest", 429 | "script": { 430 | "type": "text/javascript", 431 | "exec": [ 432 | "" 433 | ] 434 | } 435 | }, 436 | { 437 | "listen": "test", 438 | "script": { 439 | "type": "text/javascript", 440 | "exec": [ 441 | "" 442 | ] 443 | } 444 | } 445 | ], 446 | "variable": [ 447 | { 448 | "key": "email", 449 | "value": "test@example.com" 450 | }, 451 | { 452 | "key": "password", 453 | "value": "Password456!" 454 | }, 455 | { 456 | "key": "name", 457 | "value": "Jane Doe" 458 | }, 459 | { 460 | "key": "accessToken", 461 | "value": "" 462 | }, 463 | { 464 | "key": "refreshToken", 465 | "value": "" 466 | }, 467 | { 468 | "key": "endpoint", 469 | "value": "http://localhost:1337" 470 | }, 471 | { 472 | "key": "productId", 473 | "value": "" 474 | } 475 | ] 476 | } -------------------------------------------------------------------------------- /src/__tests__/product.test.ts: -------------------------------------------------------------------------------- 1 | import supertest from "supertest"; 2 | import { MongoMemoryServer } from "mongodb-memory-server"; 3 | import createServer from "../utils/server"; 4 | import mongoose from "mongoose"; 5 | import { createProduct } from "../service/product.service"; 6 | import { signJwt } from "../utils/jwt.utils"; 7 | 8 | const app = createServer(); 9 | 10 | const userId = new mongoose.Types.ObjectId().toString(); 11 | 12 | export const productPayload = { 13 | user: userId, 14 | title: "Canon EOS 1500D DSLR Camera with 18-55mm Lens", 15 | description: 16 | "Designed for first-time DSLR owners who want impressive results straight out of the box, capture those magic moments no matter your level with the EOS 1500D. With easy to use automatic shooting modes, large 24.1 MP sensor, Canon Camera Connect app integration and built-in feature guide, EOS 1500D is always ready to go.", 17 | price: 879.99, 18 | image: "https://i.imgur.com/QlRphfQ.jpg", 19 | }; 20 | 21 | export const userPayload = { 22 | _id: userId, 23 | email: "jane.doe@example.com", 24 | name: "Jane Doe", 25 | }; 26 | 27 | describe("product", () => { 28 | beforeAll(async () => { 29 | const mongoServer = await MongoMemoryServer.create(); 30 | 31 | await mongoose.connect(mongoServer.getUri()); 32 | }); 33 | 34 | afterAll(async () => { 35 | await mongoose.disconnect(); 36 | await mongoose.connection.close(); 37 | }); 38 | 39 | describe("get product route", () => { 40 | describe("given the product does not exist", () => { 41 | it("should return a 404", async () => { 42 | const productId = "product-123"; 43 | 44 | await supertest(app).get(`/api/products/${productId}`).expect(404); 45 | }); 46 | }); 47 | 48 | describe("given the product does exist", () => { 49 | it("should return a 200 status and the product", async () => { 50 | // @ts-ignore 51 | const product = await createProduct(productPayload); 52 | 53 | const { body, statusCode } = await supertest(app).get( 54 | `/api/products/${product.productId}` 55 | ); 56 | 57 | expect(statusCode).toBe(200); 58 | 59 | expect(body.productId).toBe(product.productId); 60 | }); 61 | }); 62 | }); 63 | 64 | describe("create product route", () => { 65 | describe("given the user is not logged in", () => { 66 | it("should return a 403", async () => { 67 | const { statusCode } = await supertest(app).post("/api/products"); 68 | 69 | expect(statusCode).toBe(403); 70 | }); 71 | }); 72 | 73 | describe("given the user is logged in", () => { 74 | it("should return a 200 and create the product", async () => { 75 | const jwt = signJwt(userPayload); 76 | 77 | const { statusCode, body } = await supertest(app) 78 | .post("/api/products") 79 | .set("Authorization", `Bearer ${jwt}`) 80 | .send(productPayload); 81 | 82 | expect(statusCode).toBe(200); 83 | 84 | expect(body).toEqual({ 85 | __v: 0, 86 | _id: expect.any(String), 87 | createdAt: expect.any(String), 88 | description: 89 | "Designed for first-time DSLR owners who want impressive results straight out of the box, capture those magic moments no matter your level with the EOS 1500D. With easy to use automatic shooting modes, large 24.1 MP sensor, Canon Camera Connect app integration and built-in feature guide, EOS 1500D is always ready to go.", 90 | image: "https://i.imgur.com/QlRphfQ.jpg", 91 | price: 879.99, 92 | productId: expect.any(String), 93 | title: "Canon EOS 1500D DSLR Camera with 18-55mm Lens", 94 | updatedAt: expect.any(String), 95 | user: expect.any(String), 96 | }); 97 | }); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/__tests__/user.test.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import supertest from "supertest"; 3 | import createServer from "../utils/server"; 4 | import * as UserService from "../service/user.service"; 5 | import * as SessionService from "../service/session.service"; 6 | import { createUserSessionHandler } from "../controller/session.controller"; 7 | 8 | const app = createServer(); 9 | 10 | const userId = new mongoose.Types.ObjectId().toString(); 11 | 12 | const userPayload = { 13 | _id: userId, 14 | email: "jane.doe@example.com", 15 | name: "Jane Doe", 16 | }; 17 | 18 | const userInput = { 19 | email: "test@example.com", 20 | name: "Jane Doe", 21 | password: "Password123", 22 | passwordConfirmation: "Password123", 23 | }; 24 | 25 | const sessionPayload = { 26 | _id: new mongoose.Types.ObjectId().toString(), 27 | user: userId, 28 | valid: true, 29 | userAgent: "PostmanRuntime/7.28.4", 30 | createdAt: new Date("2021-09-30T13:31:07.674Z"), 31 | updatedAt: new Date("2021-09-30T13:31:07.674Z"), 32 | __v: 0, 33 | }; 34 | 35 | describe("user", () => { 36 | // user registration 37 | 38 | describe("user registration", () => { 39 | describe("given the username and password are valid", () => { 40 | it("should return the user payload", async () => { 41 | const createUserServiceMock = jest 42 | .spyOn(UserService, "createUser") 43 | // @ts-ignore 44 | .mockReturnValueOnce(userPayload); 45 | 46 | const { statusCode, body } = await supertest(app) 47 | .post("/api/users") 48 | .send(userInput); 49 | 50 | expect(statusCode).toBe(200); 51 | 52 | expect(body).toEqual(userPayload); 53 | 54 | expect(createUserServiceMock).toHaveBeenCalledWith(userInput); 55 | }); 56 | }); 57 | 58 | describe("given the passwords do not match", () => { 59 | it("should return a 400", async () => { 60 | const createUserServiceMock = jest 61 | .spyOn(UserService, "createUser") 62 | // @ts-ignore 63 | .mockReturnValueOnce(userPayload); 64 | 65 | const { statusCode } = await supertest(app) 66 | .post("/api/users") 67 | .send({ ...userInput, passwordConfirmation: "doesnotmatch" }); 68 | 69 | expect(statusCode).toBe(400); 70 | 71 | expect(createUserServiceMock).not.toHaveBeenCalled(); 72 | }); 73 | }); 74 | 75 | describe("given the user service throws", () => { 76 | it("should return a 409 error", async () => { 77 | const createUserServiceMock = jest 78 | .spyOn(UserService, "createUser") 79 | .mockRejectedValueOnce("Oh no! :("); 80 | 81 | const { statusCode } = await supertest(createServer()) 82 | .post("/api/users") 83 | .send(userInput); 84 | 85 | expect(statusCode).toBe(409); 86 | 87 | expect(createUserServiceMock).toHaveBeenCalled(); 88 | }); 89 | }); 90 | }); 91 | 92 | describe("create user session", () => { 93 | describe("given the username and password are valid", () => { 94 | it("should return a signed accessToken & refresh token", async () => { 95 | jest 96 | .spyOn(UserService, "validatePassword") 97 | // @ts-ignore 98 | .mockReturnValue(userPayload); 99 | 100 | jest 101 | .spyOn(SessionService, "createSession") 102 | // @ts-ignore 103 | .mockReturnValue(sessionPayload); 104 | 105 | const req = { 106 | get: () => { 107 | return "a user agent"; 108 | }, 109 | body: { 110 | email: "test@example.com", 111 | password: "Password123", 112 | }, 113 | }; 114 | 115 | const send = jest.fn(); 116 | 117 | const res = { 118 | send, 119 | }; 120 | 121 | // @ts-ignore 122 | await createUserSessionHandler(req, res); 123 | 124 | expect(send).toHaveBeenCalledWith({ 125 | accessToken: expect.any(String), 126 | refreshToken: expect.any(String), 127 | }); 128 | }); 129 | }); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import config from "config"; 3 | import connect from "./utils/connect"; 4 | import logger from "./utils/logger"; 5 | import createServer from "./utils/server"; 6 | 7 | const port = config.get("port"); 8 | 9 | const app = createServer(); 10 | 11 | app.listen(port, async () => { 12 | logger.info(`App is running at http://localhost:${port}`); 13 | 14 | await connect(); 15 | }); 16 | -------------------------------------------------------------------------------- /src/controller/product.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { 3 | CreateProductInput, 4 | UpdateProductInput, 5 | } from "../schema/product.schema"; 6 | import { 7 | createProduct, 8 | deleteProduct, 9 | findAndUpdateProduct, 10 | findProduct, 11 | } from "../service/product.service"; 12 | 13 | export async function createProductHandler( 14 | req: Request<{}, {}, CreateProductInput["body"]>, 15 | res: Response 16 | ) { 17 | const userId = res.locals.user._id; 18 | 19 | const body = req.body; 20 | 21 | const product = await createProduct({ ...body, user: userId }); 22 | 23 | return res.send(product); 24 | } 25 | 26 | export async function updateProductHandler( 27 | req: Request, 28 | res: Response 29 | ) { 30 | const userId = res.locals.user._id; 31 | 32 | const productId = req.params.productId; 33 | const update = req.body; 34 | 35 | const product = await findProduct({ productId }); 36 | 37 | if (!product) { 38 | return res.sendStatus(404); 39 | } 40 | 41 | if (String(product.user) !== userId) { 42 | return res.sendStatus(403); 43 | } 44 | 45 | const updatedProduct = await findAndUpdateProduct({ productId }, update, { 46 | new: true, 47 | }); 48 | 49 | return res.send(updatedProduct); 50 | } 51 | 52 | export async function getProductHandler( 53 | req: Request, 54 | res: Response 55 | ) { 56 | const productId = req.params.productId; 57 | const product = await findProduct({ productId }); 58 | 59 | if (!product) { 60 | return res.sendStatus(404); 61 | } 62 | 63 | return res.send(product); 64 | } 65 | 66 | export async function deleteProductHandler( 67 | req: Request, 68 | res: Response 69 | ) { 70 | const userId = res.locals.user._id; 71 | const productId = req.params.productId; 72 | 73 | const product = await findProduct({ productId }); 74 | 75 | if (!product) { 76 | return res.sendStatus(404); 77 | } 78 | 79 | if (String(product.user) !== userId) { 80 | return res.sendStatus(403); 81 | } 82 | 83 | await deleteProduct({ productId }); 84 | 85 | return res.sendStatus(200); 86 | } 87 | -------------------------------------------------------------------------------- /src/controller/session.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import config from "config"; 3 | import { 4 | createSession, 5 | findSessions, 6 | updateSession, 7 | } from "../service/session.service"; 8 | import { validatePassword } from "../service/user.service"; 9 | import { signJwt } from "../utils/jwt.utils"; 10 | 11 | export async function createUserSessionHandler(req: Request, res: Response) { 12 | // Validate the user's password 13 | const user = await validatePassword(req.body); 14 | 15 | if (!user) { 16 | return res.status(401).send("Invalid email or password"); 17 | } 18 | 19 | // create a session 20 | const session = await createSession(user._id, req.get("user-agent") || ""); 21 | 22 | // create an access token 23 | 24 | const accessToken = signJwt( 25 | { ...user, session: session._id }, 26 | { expiresIn: config.get("accessTokenTtl") } // 15 minutes 27 | ); 28 | 29 | // create a refresh token 30 | const refreshToken = signJwt( 31 | { ...user, session: session._id }, 32 | { expiresIn: config.get("refreshTokenTtl") } // 15 minutes 33 | ); 34 | 35 | // return access & refresh tokens 36 | 37 | return res.send({ accessToken, refreshToken }); 38 | } 39 | 40 | export async function getUserSessionsHandler(req: Request, res: Response) { 41 | const userId = res.locals.user._id; 42 | 43 | const sessions = await findSessions({ user: userId, valid: true }); 44 | 45 | return res.send(sessions); 46 | } 47 | 48 | export async function deleteSessionHandler(req: Request, res: Response) { 49 | const sessionId = res.locals.user.session; 50 | 51 | await updateSession({ _id: sessionId }, { valid: false }); 52 | 53 | return res.send({ 54 | accessToken: null, 55 | refreshToken: null, 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /src/controller/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { omit } from "lodash"; 3 | import { CreateUserInput } from "../schema/user.schema"; 4 | import { createUser } from "../service/user.service"; 5 | import logger from "../utils/logger"; 6 | 7 | export async function createUserHandler( 8 | req: Request<{}, {}, CreateUserInput["body"]>, 9 | res: Response 10 | ) { 11 | try { 12 | const user = await createUser(req.body); 13 | 14 | return res.send(user); 15 | } catch (e: any) { 16 | logger.error(e); 17 | return res.status(409).send(e.message); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/middleware/deserializeUser.ts: -------------------------------------------------------------------------------- 1 | import { get } from "lodash"; 2 | import { Request, Response, NextFunction } from "express"; 3 | import { verifyJwt } from "../utils/jwt.utils"; 4 | import { reIssueAccessToken } from "../service/session.service"; 5 | 6 | const deserializeUser = async ( 7 | req: Request, 8 | res: Response, 9 | next: NextFunction 10 | ) => { 11 | const accessToken = get(req, "headers.authorization", "").replace( 12 | /^Bearer\s/, 13 | "" 14 | ); 15 | 16 | const refreshToken = get(req, "headers.x-refresh"); 17 | 18 | if (!accessToken) { 19 | return next(); 20 | } 21 | 22 | const { decoded, expired } = verifyJwt(accessToken); 23 | 24 | if (decoded) { 25 | res.locals.user = decoded; 26 | return next(); 27 | } 28 | 29 | if (expired && refreshToken) { 30 | const newAccessToken = await reIssueAccessToken({ refreshToken }); 31 | 32 | if (newAccessToken) { 33 | res.setHeader("x-access-token", newAccessToken); 34 | } 35 | 36 | const result = verifyJwt(newAccessToken as string); 37 | 38 | res.locals.user = result.decoded; 39 | return next(); 40 | } 41 | 42 | return next(); 43 | }; 44 | 45 | export default deserializeUser; 46 | -------------------------------------------------------------------------------- /src/middleware/requireUser.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | 3 | const requireUser = (req: Request, res: Response, next: NextFunction) => { 4 | const user = res.locals.user; 5 | 6 | if (!user) { 7 | return res.sendStatus(403); 8 | } 9 | 10 | return next(); 11 | }; 12 | 13 | export default requireUser; 14 | -------------------------------------------------------------------------------- /src/middleware/validateResource.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | import { AnyZodObject } from "zod"; 3 | 4 | const validate = 5 | (schema: AnyZodObject) => 6 | (req: Request, res: Response, next: NextFunction) => { 7 | try { 8 | schema.parse({ 9 | body: req.body, 10 | query: req.query, 11 | params: req.params, 12 | }); 13 | next(); 14 | } catch (e: any) { 15 | return res.status(400).send(e.errors); 16 | } 17 | }; 18 | 19 | export default validate; 20 | -------------------------------------------------------------------------------- /src/models/product.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { customAlphabet } from "nanoid"; 3 | import { UserDocument } from "./user.model"; 4 | 5 | const nanoid = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 10); 6 | 7 | export interface ProductDocument extends mongoose.Document { 8 | user: UserDocument["_id"]; 9 | productId: string; 10 | title: string; 11 | description: string; 12 | price: number; 13 | image: string; 14 | createdAt: Date; 15 | updatedAt: Date; 16 | } 17 | 18 | const productSchema = new mongoose.Schema( 19 | { 20 | productId: { 21 | type: String, 22 | required: true, 23 | unique: true, 24 | default: () => `product_${nanoid()}`, 25 | }, 26 | user: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, 27 | title: { type: String, required: true }, 28 | description: { type: String, required: true }, 29 | price: { type: Number, required: true }, 30 | image: { type: String, required: true }, 31 | }, 32 | { 33 | timestamps: true, 34 | } 35 | ); 36 | 37 | const ProductModel = mongoose.model("Product", productSchema); 38 | 39 | export default ProductModel; 40 | -------------------------------------------------------------------------------- /src/models/session.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { UserDocument } from "./user.model"; 3 | 4 | export interface SessionDocument extends mongoose.Document { 5 | user: UserDocument["_id"]; 6 | valid: boolean; 7 | userAgent: string; 8 | createdAt: Date; 9 | updatedAt: Date; 10 | } 11 | 12 | const sessionSchema = new mongoose.Schema( 13 | { 14 | user: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, 15 | valid: { type: Boolean, default: true }, 16 | userAgent: { type: String }, 17 | }, 18 | { 19 | timestamps: true, 20 | } 21 | ); 22 | 23 | const SessionModel = mongoose.model("Session", sessionSchema); 24 | 25 | export default SessionModel; 26 | -------------------------------------------------------------------------------- /src/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import bcrypt from "bcrypt"; 3 | import config from "config"; 4 | 5 | export interface UserDocument extends mongoose.Document { 6 | email: string; 7 | name: string; 8 | password: string; 9 | createdAt: Date; 10 | updatedAt: Date; 11 | comparePassword(candidatePassword: string): Promise; 12 | } 13 | 14 | const userSchema = new mongoose.Schema( 15 | { 16 | email: { type: String, required: true, unique: true }, 17 | name: { type: String, required: true }, 18 | password: { type: String, required: true }, 19 | }, 20 | { 21 | timestamps: true, 22 | } 23 | ); 24 | 25 | userSchema.pre("save", async function (next) { 26 | let user = this as UserDocument; 27 | 28 | if (!user.isModified("password")) { 29 | return next(); 30 | } 31 | 32 | const salt = await bcrypt.genSalt(config.get("saltWorkFactor")); 33 | 34 | const hash = await bcrypt.hashSync(user.password, salt); 35 | 36 | user.password = hash; 37 | 38 | return next(); 39 | }); 40 | 41 | userSchema.methods.comparePassword = async function ( 42 | candidatePassword: string 43 | ): Promise { 44 | const user = this as UserDocument; 45 | 46 | return bcrypt.compare(candidatePassword, user.password).catch((e) => false); 47 | }; 48 | 49 | const UserModel = mongoose.model("User", userSchema); 50 | 51 | export default UserModel; 52 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | import { Express, Request, Response } from "express"; 2 | import { 3 | createProductHandler, 4 | getProductHandler, 5 | updateProductHandler, 6 | } from "./controller/product.controller"; 7 | import { 8 | createUserSessionHandler, 9 | getUserSessionsHandler, 10 | deleteSessionHandler, 11 | } from "./controller/session.controller"; 12 | import { createUserHandler } from "./controller/user.controller"; 13 | import requireUser from "./middleware/requireUser"; 14 | import validateResource from "./middleware/validateResource"; 15 | import { 16 | createProductSchema, 17 | deleteProductSchema, 18 | getProductSchema, 19 | updateProductSchema, 20 | } from "./schema/product.schema"; 21 | import { createSessionSchema } from "./schema/session.schema"; 22 | import { createUserSchema } from "./schema/user.schema"; 23 | 24 | function routes(app: Express) { 25 | app.get("/healthcheck", (req: Request, res: Response) => res.sendStatus(200)); 26 | 27 | app.post("/api/users", validateResource(createUserSchema), createUserHandler); 28 | 29 | app.post( 30 | "/api/sessions", 31 | validateResource(createSessionSchema), 32 | createUserSessionHandler 33 | ); 34 | 35 | app.get("/api/sessions", requireUser, getUserSessionsHandler); 36 | 37 | app.delete("/api/sessions", requireUser, deleteSessionHandler); 38 | 39 | app.post( 40 | "/api/products", 41 | [requireUser, validateResource(createProductSchema)], 42 | createProductHandler 43 | ); 44 | 45 | app.put( 46 | "/api/products/:productId", 47 | [requireUser, validateResource(updateProductSchema)], 48 | updateProductHandler 49 | ); 50 | 51 | app.get( 52 | "/api/products/:productId", 53 | validateResource(getProductSchema), 54 | getProductHandler 55 | ); 56 | 57 | app.delete( 58 | "/api/products/:productId", 59 | [requireUser, validateResource(deleteProductSchema)], 60 | getProductHandler 61 | ); 62 | } 63 | 64 | export default routes; 65 | -------------------------------------------------------------------------------- /src/schema/product.schema.ts: -------------------------------------------------------------------------------- 1 | import { object, number, string, TypeOf } from "zod"; 2 | const payload = { 3 | body: object({ 4 | title: string({ 5 | required_error: "Title is required", 6 | }), 7 | description: string({ 8 | required_error: "Description is required", 9 | }).min(120, "Description should be at least 120 characters long"), 10 | price: number({ 11 | required_error: "Price is required", 12 | }), 13 | image: string({ 14 | required_error: "Image is required", 15 | }), 16 | }), 17 | }; 18 | 19 | const params = { 20 | params: object({ 21 | productId: string({ 22 | required_error: "productId is required", 23 | }), 24 | }), 25 | }; 26 | 27 | export const createProductSchema = object({ 28 | ...payload, 29 | }); 30 | 31 | export const updateProductSchema = object({ 32 | ...payload, 33 | ...params, 34 | }); 35 | 36 | export const deleteProductSchema = object({ 37 | ...params, 38 | }); 39 | 40 | export const getProductSchema = object({ 41 | ...params, 42 | }); 43 | 44 | export type CreateProductInput = TypeOf; 45 | export type UpdateProductInput = TypeOf; 46 | export type ReadProductInput = TypeOf; 47 | export type DeleteProductInput = TypeOf; 48 | -------------------------------------------------------------------------------- /src/schema/session.schema.ts: -------------------------------------------------------------------------------- 1 | import { object, string } from "zod"; 2 | 3 | export const createSessionSchema = object({ 4 | body: object({ 5 | email: string({ 6 | required_error: "Email is required", 7 | }), 8 | password: string({ 9 | required_error: "Password is required", 10 | }), 11 | }), 12 | }); 13 | -------------------------------------------------------------------------------- /src/schema/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { object, string, TypeOf } from "zod"; 2 | 3 | export const createUserSchema = object({ 4 | body: object({ 5 | name: string({ 6 | required_error: "Name is required", 7 | }), 8 | password: string({ 9 | required_error: "Name is required", 10 | }).min(6, "Password too short - should be 6 chars minimum"), 11 | passwordConfirmation: string({ 12 | required_error: "passwordConfirmation is required", 13 | }), 14 | email: string({ 15 | required_error: "Email is required", 16 | }).email("Not a valid email"), 17 | }).refine((data) => data.password === data.passwordConfirmation, { 18 | message: "Passwords do not match", 19 | path: ["passwordConfirmation"], 20 | }), 21 | }); 22 | 23 | export type CreateUserInput = Omit< 24 | TypeOf, 25 | "body.passwordConfirmation" 26 | >; 27 | -------------------------------------------------------------------------------- /src/service/product.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentDefinition, 3 | FilterQuery, 4 | QueryOptions, 5 | UpdateQuery, 6 | } from "mongoose"; 7 | import ProductModel, { ProductDocument } from "../models/product.model"; 8 | 9 | export async function createProduct( 10 | input: DocumentDefinition< 11 | Omit 12 | > 13 | ) { 14 | return ProductModel.create(input); 15 | } 16 | 17 | export async function findProduct( 18 | query: FilterQuery, 19 | options: QueryOptions = { lean: true } 20 | ) { 21 | return ProductModel.findOne(query, {}, options); 22 | } 23 | 24 | export async function findAndUpdateProduct( 25 | query: FilterQuery, 26 | update: UpdateQuery, 27 | options: QueryOptions 28 | ) { 29 | return ProductModel.findOneAndUpdate(query, update, options); 30 | } 31 | 32 | export async function deleteProduct(query: FilterQuery) { 33 | return ProductModel.deleteOne(query); 34 | } 35 | -------------------------------------------------------------------------------- /src/service/session.service.ts: -------------------------------------------------------------------------------- 1 | import { get } from "lodash"; 2 | import config from "config"; 3 | import { FilterQuery, UpdateQuery } from "mongoose"; 4 | import SessionModel, { SessionDocument } from "../models/session.model"; 5 | import { verifyJwt, signJwt } from "../utils/jwt.utils"; 6 | import { findUser } from "./user.service"; 7 | 8 | export async function createSession(userId: string, userAgent: string) { 9 | const session = await SessionModel.create({ user: userId, userAgent }); 10 | 11 | return session.toJSON(); 12 | } 13 | 14 | export async function findSessions(query: FilterQuery) { 15 | return SessionModel.find(query).lean(); 16 | } 17 | 18 | export async function updateSession( 19 | query: FilterQuery, 20 | update: UpdateQuery 21 | ) { 22 | return SessionModel.updateOne(query, update); 23 | } 24 | 25 | export async function reIssueAccessToken({ 26 | refreshToken, 27 | }: { 28 | refreshToken: string; 29 | }) { 30 | const { decoded } = verifyJwt(refreshToken); 31 | 32 | if (!decoded || !get(decoded, "session")) return false; 33 | 34 | const session = await SessionModel.findById(get(decoded, "session")); 35 | 36 | if (!session || !session.valid) return false; 37 | 38 | const user = await findUser({ _id: session.user }); 39 | 40 | if (!user) return false; 41 | 42 | const accessToken = signJwt( 43 | { ...user, session: session._id }, 44 | { expiresIn: config.get("accessTokenTtl") } // 15 minutes 45 | ); 46 | 47 | return accessToken; 48 | } 49 | -------------------------------------------------------------------------------- /src/service/user.service.ts: -------------------------------------------------------------------------------- 1 | import { DocumentDefinition, FilterQuery } from "mongoose"; 2 | import { omit } from "lodash"; 3 | import UserModel, { UserDocument } from "../models/user.model"; 4 | 5 | export async function createUser( 6 | input: DocumentDefinition< 7 | Omit 8 | > 9 | ) { 10 | try { 11 | const user = await UserModel.create(input); 12 | 13 | return omit(user.toJSON(), "password"); 14 | } catch (e: any) { 15 | throw new Error(e); 16 | } 17 | } 18 | 19 | export async function validatePassword({ 20 | email, 21 | password, 22 | }: { 23 | email: string; 24 | password: string; 25 | }) { 26 | const user = await UserModel.findOne({ email }); 27 | 28 | if (!user) { 29 | return false; 30 | } 31 | 32 | const isValid = await user.comparePassword(password); 33 | 34 | if (!isValid) return false; 35 | 36 | return omit(user.toJSON(), "password"); 37 | } 38 | 39 | export async function findUser(query: FilterQuery) { 40 | return UserModel.findOne(query).lean(); 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/connect.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import config from "config"; 3 | import logger from "./logger"; 4 | 5 | async function connect() { 6 | const dbUri = config.get("dbUri"); 7 | 8 | try { 9 | await mongoose.connect(dbUri); 10 | logger.info("DB connected"); 11 | } catch (error) { 12 | logger.error("Could not connect to db"); 13 | process.exit(1); 14 | } 15 | } 16 | 17 | export default connect; 18 | -------------------------------------------------------------------------------- /src/utils/jwt.utils.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import config from "config"; 3 | 4 | const privateKey = config.get("privateKey"); 5 | const publicKey = config.get("publicKey"); 6 | 7 | export function signJwt(object: Object, options?: jwt.SignOptions | undefined) { 8 | return jwt.sign(object, privateKey, { 9 | ...(options && options), 10 | algorithm: "RS256", 11 | }); 12 | } 13 | 14 | export function verifyJwt(token: string) { 15 | try { 16 | const decoded = jwt.verify(token, publicKey); 17 | return { 18 | valid: true, 19 | expired: false, 20 | decoded, 21 | }; 22 | } catch (e: any) { 23 | console.error(e); 24 | return { 25 | valid: false, 26 | expired: e.message === "jwt expired", 27 | decoded: null, 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import logger from "pino"; 2 | import dayjs from "dayjs"; 3 | 4 | const log = logger({ 5 | prettyPrint: true, 6 | base: { 7 | pid: false, 8 | }, 9 | timestamp: () => `,"time":"${dayjs().format()}"`, 10 | }); 11 | 12 | export default log; 13 | -------------------------------------------------------------------------------- /src/utils/server.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import routes from "../routes"; 3 | import deserializeUser from "../middleware/deserializeUser"; 4 | 5 | function createServer() { 6 | const app = express(); 7 | 8 | app.use(express.json()); 9 | 10 | app.use(deserializeUser); 11 | 12 | routes(app); 13 | 14 | return app; 15 | } 16 | 17 | export default createServer; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | "outDir": "build", 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es5" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs" /* Specify what module code is generated. */, 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | 68 | /* Interop Constraints */ 69 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 70 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 71 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 72 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 73 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 74 | 75 | /* Type Checking */ 76 | "strict": true /* Enable all strict type-checking options. */, 77 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 78 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 79 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 80 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 81 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 82 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 83 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 84 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 85 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 86 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 87 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 88 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 89 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 90 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 91 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 92 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 93 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 94 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 95 | 96 | /* Completeness */ 97 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 98 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 99 | } 100 | } 101 | --------------------------------------------------------------------------------