diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..6554e1f --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,26 @@ +# Use the latest 2.1 version of CircleCI pipeline process engine. +# See: https://circleci.com/docs/2.0/configuration-reference +version: 2.1 + +# Define a job to be invoked later in a workflow. +# See: https://circleci.com/docs/2.0/configuration-reference/#jobs +jobs: + say-hello: + # Specify the execution environment. You can specify an image from Dockerhub or use one of our Convenience Images from CircleCI's Developer Hub. + # See: https://circleci.com/docs/2.0/configuration-reference/#docker-machine-macos-windows-executor + docker: + - image: cimg/base:stable + # Add steps to the job + # See: https://circleci.com/docs/2.0/configuration-reference/#steps + steps: + - checkout + - run: + name: "Say hello" + command: "echo Hello, World!" + +# Invoke jobs via workflows +# See: https://circleci.com/docs/2.0/configuration-reference/#workflows +workflows: + say-hello-workflow: + jobs: + - say-hello diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7381738 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.DS_Store +.git +.gitignore +__mocks__ +README.md +LICENSE +coverage +jest.config.js +node_modules +scripts +**/__tests__/** +src/tests diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..276da8d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# base for our image, based on buildpack-deps, based on Debian Linux +FROM node:lts + +# Create app directory +WORKDIR /opt/api-example + +# Install app dependencies +COPY package.json ./ +COPY yarn.lock ./ +RUN yarn install --production + +# Build JavaScript from TypeScript +COPY . . +RUN NODE_OPTIONS=--max-old-space-size=8192 yarn build + +# Tell docker which port will be used (not published) +EXPOSE 3000 + +# Default env file +ENV ENV_FILE=config/.env.prod + +# Run this app when a container is launched +CMD [ "node", "-r", "tsconfig-paths/register", "bin/app.js" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f385405 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 losikov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9d7ad9 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Backend API Server Development with Node.js from Scratch to Production + +This repo is a code base for [the tutorial](https://medium.com/@losikov/backend-api-server-development-with-node-js-from-scratch-to-production-fe3d3b860003). + +## Contents +[Part 1. Project initial setup: TypeScript + Node.js](https://medium.com/@losikov/part-1-project-initial-setup-typescript-node-js-31ba3aa7fbf1) + +[Part 2. Express Routing with Open API 3.0 and Swagger UI/Editor](https://medium.com/@losikov/part-2-express-open-api-3-0-634385c97a4e) + +[Part 3. Brushing up: Logger + Environment variables](https://medium.com/@losikov/part-3-brushing-up-get-more-from-node-js-express-open-api-3-0-4ce482ffa958) + +[Part 4. Node.js + Express + TypeScript: Unit Tests with Jest](https://medium.com/@losikov/part-4-node-js-express-typescript-unit-tests-with-jest-5204414bf6f0) + +[Part 5. MongoDB with mongoose](https://medium.com/@losikov/part-5-mongodb-with-mongoose-d01144739002) + +[Part 6. Authentication with JWT, JSON Web Token](https://medium.com/@losikov/part-6-authentication-with-jwt-json-web-token-ec78459b9c88) + +[Part 7. Internal Caching in Node.js](https://medium.com/@losikov/part-7-internal-caching-in-node-js-3f18411bcf2) + +[Part 8. External Caching in Node.js with Redis](https://medium.com/@losikov/part-8-external-caching-in-node-js-with-redis-2f12607c995) + +[Part 9. Docker, Docker Compose, Complete Intro](https://medium.com/@losikov/part-9-docker-docker-compose-complete-intro-2cfcc510bd8e) + +*Stay tuned for the next parts...* + +[Appendix A. The Project Structure](https://medium.com/@losikov/appendix-a-the-project-structure-863edab1469e) diff --git a/__mocks__/redis.ts b/__mocks__/redis.ts new file mode 100644 index 0000000..35054a6 --- /dev/null +++ b/__mocks__/redis.ts @@ -0,0 +1,116 @@ +/* eslint @typescript-eslint/no-explicit-any: 0 */ +/* eslint no-bitwise: 0 */ + +import fst, {RedisStorageType} from './redis_storage' + +type Callback = (err: Error | null, reply: T)=> void; + +const redis: any = jest.genMockFromModule('redis') + +class RedisClient { + _callbacks: Map + + _storage: Map + + constructor() { + this._callbacks = new Map() + this._storage = new Map() + } + + callReconnectCallbacks(): void { + const error = this._callbacks.get('error') + if (error !== undefined) error(new Error('mock-redis')) + + const disconnected = this._callbacks.get('disconnected') + if (disconnected !== undefined) disconnected() + + const reconnecting = this._callbacks.get('reconnecting') + if (reconnecting !== undefined) reconnecting() + + const connected = this._callbacks.get('connected') + if (connected !== undefined) connected() + + const ready = this._callbacks.get('ready') + if (ready !== undefined) ready() + } + + on(state: string, callback: any): void { + this._callbacks.set(state, callback) + if (state === 'ready' || state === 'connected') { + callback() + } + } + + setex(key: string, timeout: number, value: string, callback: Callback): boolean { + if (fst.has(key) || fst.has(value)) { + if (fst.type(key) & RedisStorageType.returnSet || fst.type(value) & RedisStorageType.returnSet) { + this.callReconnectCallbacks() + return false + } else if (fst.type(key) & RedisStorageType.callbackSet || fst.type(value) & RedisStorageType.callbackSet) { + callback(new Error('Redis connection error'), null) + this.callReconnectCallbacks() + return true + } + } + + this._storage.set(key, value) + callback(null, 'Ok') + return true + } + + hset(key: string, key2: number, value: string, callback: Callback): boolean { + return this.setex(key, 0, value, callback) + } + + del(key: string, callback: Callback): boolean { + if (fst.has(key)) { + if (fst.type(key) & RedisStorageType.returnDelete) { + this.callReconnectCallbacks() + return false + } else if (fst.type(key) & RedisStorageType.callbackDelete) { + callback(new Error('Redis connection error'), null) + this.callReconnectCallbacks() + return true + } + } + + this._storage.delete(key) + callback(null, '1') + return true + } + + get(key: string, callback: Callback): boolean { + if (fst.has(key)) { + if (fst.type(key) & RedisStorageType.returnGet) { + this.callReconnectCallbacks() + return false + } else if (fst.type(key) & RedisStorageType.callbackGet) { + callback(new Error('Redis connection error'), null) + this.callReconnectCallbacks() + return true + } + } + + const value = this._storage.get(key) + callback(null, value !== undefined ? value : null) + return true + } + + hmget(key: string, key2: string, callback: Callback): boolean { + return this.get(key, callback) + } + + quit(cb: ()=> void): void { + const end = this._callbacks.get('end') + if (end !== undefined) end() + cb() + } +} + +function createClient(): RedisClient { + return new RedisClient() +} + +redis.createClient = createClient + +module.exports = redis diff --git a/__mocks__/redis_storage.ts b/__mocks__/redis_storage.ts new file mode 100644 index 0000000..a7d7e50 --- /dev/null +++ b/__mocks__/redis_storage.ts @@ -0,0 +1,51 @@ +/* eslint no-bitwise: 0 */ + +export enum RedisStorageType { + default = 0, + returnSet = 1 << 0, + returnGet = 1 << 1, + returnDelete = 1 << 2, + returnAll = returnSet | returnGet | returnDelete, + callbackSet = 1 << 3, + callbackGet = 1 << 4, + callbackDelete = 1 << 5, + callbackAll = returnSet | returnGet | returnDelete, +} + +class RedisStorage { + private static _instance: RedisStorage + + private _failover: Map + + private constructor() { + this._failover = new Map() + } + + public static getInstance(): RedisStorage { + if (!RedisStorage._instance) { + RedisStorage._instance = new RedisStorage() + } + return RedisStorage._instance + } + + public addFailover(token: string, type: RedisStorageType = RedisStorageType.default): this { + this._failover.set(token, type) + return this + } + + public clear() { + this._failover.clear() + } + + public has(token: string): boolean { + return this._failover.has(token) + } + + public type(token: string): RedisStorageType { + let v = this._failover.get(token) + if (v === undefined) v = RedisStorageType.default + return v + } +} + +export default RedisStorage.getInstance() diff --git a/config/.env.dev b/config/.env.dev index dc5b191..c23d930 100644 --- a/config/.env.dev +++ b/config/.env.dev @@ -1,3 +1,5 @@ +REDIS_URL=redis://localhost:6379 + MONGO_URL=mongodb://localhost/exmpl MONGO_AUTO_INDEX=true diff --git a/config/.env.prod b/config/.env.prod index 3f65466..8a47273 100644 --- a/config/.env.prod +++ b/config/.env.prod @@ -1,3 +1,5 @@ +REDIS_URL=redis://localhost:6379 + MONGO_URL=mongodb://localhost/exmpl MONGO_AUTO_INDEX=false diff --git a/config/.env.schema b/config/.env.schema index 44aa1da..8a7278e 100644 --- a/config/.env.schema +++ b/config/.env.schema @@ -6,6 +6,8 @@ PUBLIC_KEY_FILE= LOCAL_CACHE_TTL= +REDIS_URL= + MONGO_URL= MONGO_CREATE_INDEX= MONGO_AUTO_INDEX= diff --git a/config/.env.test b/config/.env.test index 07efe79..261ff73 100644 --- a/config/.env.test +++ b/config/.env.test @@ -1,2 +1,4 @@ +REDIS_URL=redis-mock + MONGO_URL=inmemory MONGO_AUTO_INDEX=true diff --git a/config/swarm/docker-compose.yml b/config/swarm/docker-compose.yml new file mode 100644 index 0000000..7f877da --- /dev/null +++ b/config/swarm/docker-compose.yml @@ -0,0 +1,120 @@ +version: "3.8" + +# virtual networks inside cluster, to let runing containers talk to each other +networks: + nginx-reverse: # root nginx-reverse to api-example and wp + api-db: # api-example to mongo and redis + wp-db: # wp to mysql + +# declare all services: nginx-reverse (api-example (redis, mongo), wp (mysql)) +services: + # root service: nginx as reverse-proxy for 1) api-example and 2) wp + nginx-reverse: + image: nginx + restart: always + deploy: + placement: # to run this service on a specific node + constraints: + - 'node.role == manager' + ports: # exposed ports out of cluster + - 80:80 + # - 443:443 # if you have certs + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro,delegated + - ./nginx-reverse/nginx.conf:/etc/nginx/conf.d/nginx.conf:ro,delegated + # - ./nginx-reverse/fullchain.pem:/etc/nginx/certs/fullchain.pem:ro,delegated # if you have certs + # - ./nginx-reverse/privkey.pem:/etc/nginx/certs/privkey.pem:ro,delegated # if you have certs + networks: + - nginx-reverse + depends_on: # do not start service until dependencies are started + - api-example + - wp + + # 1) api-example + redis + mongo + api-example: + image: losikov/api-example # specify your repo name + build: # to build image with docker-compose + context: ../../ + dockerfile: ./Dockerfile + restart: always + # deploy: # uncomment to run multiple api-example services + # replicas: 4 + # resources: + # limits: + # cpus: "1.5" + # memory: 256M + environment: + - MONGO_URL=mongodb://mongo/exmpl # 'mongo' is a name (hostname) of container + - REDIS_URL=redis://redis:6379 # 'redis' is a name (hostname) of container + networks: + - nginx-reverse + - api-db + depends_on: # do not start service until dependencies are started + - redis + - mongo + + redis: + image: redis + restart: always + # ports: # uncomment if you would like to connect from host + # - 6379:6379 + command: redis-server --save '' + networks: + - api-db + + mongo: + image: mongo + restart: always + deploy: + placement: # to run this service on a specific node + constraints: + - 'node.role == manager' + # ports: # uncomment if you would like to connect from host + # - 27017:27017 + volumes: + - ../../../docker/mongodb:/data/db:delegated + networks: + - api-db + + # 2) WordPress + mysql + wp: + image: wordpress:latest + restart: always + # deploy: # uncomment to run multiple api-example services + # replicas: 2 + # resources: + # limits: + # cpus: "2.0" + # memory: 1024M + environment: + WORDPRESS_DB_HOST: mysql # 'mysql' is a name (hostname) of container + WORDPRESS_DB_USER: wp + WORDPRESS_DB_PASSWORD: your_mysql_pasword + WORDPRESS_DB_NAME: wp + ports: # exposed ports out of cluster (to fix host name after first launch, and restart) + - 8080:80 # comment me after fixing wp hostname + networks: + - nginx-reverse + - wp-db + depends_on: # do not start service until dependencies are started + - mysql + + mysql: + image: mysql:8 + restart: always + deploy: + placement: # to run this service on a specific node + constraints: + - 'node.role == manager' + command: "--default-authentication-plugin=mysql_native_password" + cap_add: # mbind: Operation not permitted issue + - SYS_NICE # CAP_SYS_NICE + environment: + MYSQL_ROOT_PASSWORD: your_mysql_root_password + MYSQL_DATABASE: wp + MYSQL_USER: wp + MYSQL_PASSWORD: your_mysql_pasword + volumes: + - ../../../docker/mysql:/var/lib/mysql + networks: + - wp-db \ No newline at end of file diff --git a/config/swarm/nginx-reverse/nginx.conf b/config/swarm/nginx-reverse/nginx.conf new file mode 100644 index 0000000..51f459e --- /dev/null +++ b/config/swarm/nginx-reverse/nginx.conf @@ -0,0 +1,44 @@ +# if you have certificates, uncomment to redirect HTTP (80) to HTTPS (443) +# server { +# listen 80 default_server; +# server_name _; +# return 301 https://$host$request_uri; +# } + +server { + # if SSL, comment next 3 lines: + listen 80; + listen [::]:80; + server_name api-example.local *.api-example.local; + + # and uncomment all next + # listen 443 ssl; + # listen [::]:443 ssl; + # server_name api-example.org *.api-example.org; + + # if you have certificates + # ssl_certificate /etc/nginx/certs/fullchain.pem; + # ssl_certificate_key /etc/nginx/certs/privkey.pem; + + # to compress all outgoing and incoming json which size > 1024 bytes + gzip on; + gzip_min_length 1024; + gzip_comp_level 5; + gzip_proxied any; + gzip_vary on; + gzip_types application/json; + + gunzip on; + + # /api/v1 redirect to api-example + location /api/v1 { + proxy_pass http://api-example:3000; + } + + # place for your another service, or nginx with static content, etc + + # all other traffic to WordPress + location / { + proxy_pass http://wp; + } +} diff --git a/package.json b/package.json index 3d99032..2a56859 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@types/mongoose": "^5.7.36", "@types/morgan": "^1.9.1", "@types/node": "^14.0.27", + "@types/redis": "^2.8.27", "@types/validator": "^13.1.0", "@types/yamljs": "^0.2.31", "bcrypt": "^5.0.0", @@ -37,6 +38,7 @@ "morgan": "^1.10.0", "morgan-body": "^2.5.0", "node-cache": "^5.1.2", + "redis": "^3.0.2", "swagger-routes-express": "^3.2.1", "tsconfig-paths": "^3.9.0", "typescript": "^3.9.7", @@ -46,8 +48,10 @@ }, "devDependencies": { "@types/jest": "^26.0.9", + "@types/redis-mock": "^0.17.0", "@types/supertest": "^2.0.10", "jest": "^26.4.0", + "redis-mock": "^0.52.0", "supertest": "^4.0.2", "ts-jest": "^26.2.0", "ts-node": "^8.10.2" diff --git a/scripts/run_dev_dbs.sh b/scripts/run_dev_dbs.sh index ea428bd..d668866 100755 --- a/scripts/run_dev_dbs.sh +++ b/scripts/run_dev_dbs.sh @@ -1,16 +1,16 @@ #!/bin/sh # # Usage: run from project directory: ./scripts/run_dev_dbs.sh -# Description: kill/clear/run mongo with docker for dev environment +# Description: kill/clear/run redis & mongo with docker for dev environment # Prerequirements: docker # usage() { echo "usage: ./scripts/run_dev_dbs.sh [-k|-c|-r]" - echo " -k|--kill : kill mongo docker containers" - echo " -c|--clear : create/clear mongodb host folder" - echo " -r|--run : run mongo container" + echo " -k|--kill : kill redis and mongo docker containers" + echo " -c|--clear : ccreate/clear mongodb host folder" + echo " -r|--run : run redis and mongo containers" echo "example: ./scripts/run_dev_dbs.sh -k -c -r" } @@ -37,7 +37,7 @@ while [ "$1" != "" ]; do done if [ "$kill" = "1" ]; then - docker kill mongo + docker kill redis mongo fi if [ "$clear" = "1" ]; then @@ -46,7 +46,11 @@ if [ "$clear" = "1" ]; then fi if [ "$run" = "1" ]; then - command -v docker >/dev/null 2>&1 || { echo >&2 "'docker' is not install installed. Aborting."; exit 1; } + command -v docker >/dev/null 2>&1 || { echo >&2 "'docker' is not installed. Aborting."; exit 1; } + + name='redis' + [[ $(docker ps -f "name=$name" --format '{{.Names}}') == $name ]] || + docker run --rm -d -p 6379:6379 --name "$name" redis --save '' name='mongo' [[ $(docker ps -f "name=$name" --format '{{.Names}}') == $name ]] || diff --git a/src/api/controllers/__tests__/greeting.ts b/src/api/controllers/__tests__/greeting.ts index 4eb710f..baeb3dc 100644 --- a/src/api/controllers/__tests__/greeting.ts +++ b/src/api/controllers/__tests__/greeting.ts @@ -1,6 +1,7 @@ import request from 'supertest' import {Express} from 'express-serve-static-core' +import cacheExternal from '@exmpl/utils/cache_external' import db from '@exmpl/utils/db' import {createServer} from '@exmpl/utils/server' import {createDummyAndAuthorize, deleteUser} from '@exmpl/tests/user' @@ -8,11 +9,13 @@ import {createDummyAndAuthorize, deleteUser} from '@exmpl/tests/user' let server: Express beforeAll(async () => { + await cacheExternal.open() await db.open() server = await createServer() }) afterAll(async () => { + await cacheExternal.close() await db.close() }) @@ -72,7 +75,7 @@ async function sendGoodbye(token: string) { }) } -it('goodbye perfromance test', async () => { +if (false) it('goodbye perfromance test', async () => { const dummy = await createDummyAndAuthorize() const now = new Date().getTime() @@ -82,7 +85,7 @@ it('goodbye perfromance test', async () => { await sendGoodbye(dummy.token) } while (new Date().getTime() - now < 1000) - // console.log(`goodbye perfromance test: ${i}`) + console.log(`goodbye perfromance test: ${i}`) }) describe('GET /goodbye', () => { diff --git a/src/api/controllers/__tests__/user.ts b/src/api/controllers/__tests__/user.ts index 2f28a6b..ba9ab22 100644 --- a/src/api/controllers/__tests__/user.ts +++ b/src/api/controllers/__tests__/user.ts @@ -3,18 +3,21 @@ import faker from 'faker' import request from 'supertest' import {Express} from 'express-serve-static-core' +import cacheExternal from '@exmpl/utils/cache_external' import db from '@exmpl/utils/db' import {createServer} from '@exmpl/utils/server' import {createDummy} from '@exmpl/tests/user' let server: Express beforeAll(async () => { + await cacheExternal.open() await db.open() server = await createServer() }) afterAll(async () => { - await db.close() + await cacheExternal.close() + await db.close() }) describe('POST /api/v1/login', () => { diff --git a/src/api/services/__tests__/user.ts b/src/api/services/__tests__/user.ts index 95ad327..38dc5db 100644 --- a/src/api/services/__tests__/user.ts +++ b/src/api/services/__tests__/user.ts @@ -1,32 +1,36 @@ import faker from 'faker' +import cacheExternal from '@exmpl/utils/cache_external' import db from '@exmpl/utils/db' import {createDummy, createDummyAndAuthorize} from '@exmpl/tests/user' import user from '../user' + beforeAll(async () => { + await cacheExternal.open() await db.open() }) afterAll(async () => { + await cacheExternal.close() await db.close() }) -it('auth perfromance test', async () => { +if (false) it('auth perfromance test', async () => { const dummy = await createDummyAndAuthorize() const now = new Date().getTime() - let i = 0 - do { + let i = 0 + do { i += 1 await user.auth(`Bearer ${dummy.token!}`) } while (new Date().getTime() - now < 1000) - // console.log(`auth perfromance test: ${i}`) + console.log(`auth perfromance test: ${i}`) }) describe('auth', () => { - it('should resolve with true and valid userId for hardcoded token', async () => { + it('should resolve with true and valid userId for valid token', async () => { const dummy = await createDummyAndAuthorize() await expect(user.auth(dummy.token)).resolves.toEqual({userId: dummy.userId}) }) diff --git a/src/api/services/__tests__/user_failure.ts b/src/api/services/__tests__/user_failure.ts index 8e4a542..209a6c5 100644 --- a/src/api/services/__tests__/user_failure.ts +++ b/src/api/services/__tests__/user_failure.ts @@ -1,9 +1,12 @@ import jwt, {Secret, SignCallback, SignOptions} from 'jsonwebtoken' +import cacheExternal from '@exmpl/utils/cache_external' import db from '@exmpl/utils/db' -import {createDummy} from '@exmpl/tests/user' +import {createDummy, createDummyAndAuthorize} from '@exmpl/tests/user' import user from '../user' +jest.mock('../../../utils/cache_external.ts') + beforeAll(async () => { await db.open() }) @@ -12,8 +15,29 @@ afterAll(async () => { await db.close() }) +afterEach(() => { + jest.clearAllMocks() +}) + +describe('auth', () => { + it('should resolve with true and valid userId for valid token if cacheExternal rejects', async () => { + (cacheExternal.setProp as jest.Mock).mockRejectedValue(new Error('connection error')); + (cacheExternal.getProp as jest.Mock).mockRejectedValue(new Error('connection error')); + const dummy = await createDummyAndAuthorize() + await expect(user.auth(dummy.token)).resolves.toEqual({userId: dummy.userId}) + }) + + it('should resolve with true and valid userId for valid token if cacheExternal rejects', async () => { + (cacheExternal.getProp as jest.Mock).mockRejectedValue(new Error('connection error')); + const dummy = await createDummyAndAuthorize(); + (cacheExternal.setProp as jest.Mock).mockResolvedValue({userId: dummy.userId}); + await expect(user.auth(dummy.token)).resolves.toEqual({userId: dummy.userId}) + }) +}) + describe('login', () => { it('should return internal_server_error if jwt.sign fails with the error', async () => { + const sign = jwt.sign; (jwt.sign as any) = (payload: string | Buffer | object, secretOrPrivateKey: Secret, options: SignOptions, @@ -25,5 +49,16 @@ describe('login', () => { await expect(user.login(dummy.email, dummy.password)).rejects.toEqual({ error: {type: 'internal_server_error', message: 'Internal Server Error'} }) + jwt.sign = sign + }) + + it('should return JWT token, userId, expireAt to a valid login/password if cacheExternal rejects', async () => { + (cacheExternal.setProp as jest.Mock).mockRejectedValueOnce(new Error('connection error')); + const dummy = await createDummy() + await expect(user.login(dummy.email, dummy.password)).resolves.toEqual({ + userId: dummy.userId, + token: expect.stringMatching(/^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$/), + expireAt: expect.any(Date) + }) }) }) diff --git a/src/api/services/user.ts b/src/api/services/user.ts index e6ec174..31e6cae 100644 --- a/src/api/services/user.ts +++ b/src/api/services/user.ts @@ -3,6 +3,7 @@ import jwt, {SignOptions, VerifyErrors, VerifyOptions} from 'jsonwebtoken' import User, {IUser} from '@exmpl/api/models/user' import config from '@exmpl/config' +import cacheExternal from '@exmpl/utils/cache_external' import cacheLocal from '@exmpl/utils/cache_local' import logger from '@exmpl/utils/logger' @@ -26,18 +27,34 @@ const verifyOptions: VerifyOptions = { algorithms: ['RS256'] } -function auth(bearerToken: string): Promise { +async function auth(bearerToken: string): Promise { + const token = bearerToken.replace('Bearer ', '') + + try { + const userId = await cacheExternal.getProp(token) + if (userId) { + return {userId: userId} + } + } catch (err) { + logger.warn(`login.cache.addToken: ${err}`) + } + return new Promise(function(resolve, reject) { - const token = bearerToken.replace('Bearer ', '') jwt.verify(token, publicKey, verifyOptions, (err: VerifyErrors | null, decoded: object | undefined) => { - if (err === null && decoded !== undefined) { - const d = decoded as {userId?: string, exp: number} - if (d.userId) { - resolve({userId: d.userId}) - return - } + if (err === null && decoded !== undefined && (decoded as any).userId !== undefined) { + const d = decoded as {userId: string, exp: number} + const expireAfter = d.exp - Math.round((new Date()).valueOf() / 1000) + cacheExternal.setProp(token, d.userId, expireAfter) + .then(() => { + resolve({userId: d.userId}) + }) + .catch((err) => { + resolve({userId: d.userId}) + logger.warn(`auth.cache.addToken: ${err}`) + }) + } else { + resolve({error: {type: 'unauthorized', message: 'Authentication Failed'}}) } - resolve({error: {type: 'unauthorized', message: 'Authentication Failed'}}) }) }) } @@ -49,8 +66,14 @@ function createAuthToken(userId: string): Promise<{token: string, expireAt: Date const expireAfter = 2 * 604800 /* two weeks */ const expireAt = new Date() expireAt.setSeconds(expireAt.getSeconds() + expireAfter) - - resolve({token: encoded, expireAt: expireAt}) + + cacheExternal.setProp(encoded, userId, expireAfter) + .then(() => { + resolve({token: encoded, expireAt: expireAt}) + }).catch(err => { + logger.warn(`createAuthToken.setProp: ${err}`) + resolve({token: encoded, expireAt: expireAt}) + }) } else { reject(err) } diff --git a/src/app.ts b/src/app.ts index 4a29219..43fd084 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,8 +1,10 @@ +import cacheExternal from './utils/cache_external' import db from '@exmpl/utils/db' import logger from '@exmpl/utils/logger' import {createServer} from '@exmpl/utils/server' -db.open() +cacheExternal.open() + .then(() => db.open()) .then(() => createServer()) .then(server => { server.listen(3000, () => { diff --git a/src/config/index.ts b/src/config/index.ts index 4b16c2d..08bffd3 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -22,6 +22,8 @@ interface Config { publicKeyFile: string, localCacheTtl: number, + + redisUrl: string, mongo: { url: string, @@ -41,6 +43,8 @@ const config: Config = { publicKeyFile: parsedEnv.PUBLIC_KEY_FILE as string, localCacheTtl: parsedEnv.LOCAL_CACHE_TTL as number, + + redisUrl: parsedEnv.REDIS_URL as string, mongo: { url: parsedEnv.MONGO_URL as string, diff --git a/src/utils/__tests__/cache_external.ts b/src/utils/__tests__/cache_external.ts new file mode 100644 index 0000000..dede461 --- /dev/null +++ b/src/utils/__tests__/cache_external.ts @@ -0,0 +1,55 @@ +/* eslint import/first: 0 */ + +import faker from 'faker' + +import config from '@exmpl/config' // to force test to use and mock'redis' rather than 'redis-mock' + +config.redisUrl = 'redis://localhost:6379' + +import cacheExternal from '@exmpl/utils/cache_external' + +import fst, {RedisStorageType} from '../../../__mocks__/redis_storage' + +jest.mock('redis') + + +beforeAll(async () => { + await cacheExternal.open() +}) + +afterAll(async () => { + await cacheExternal.close() +}) + +afterEach(() => { + fst.clear() +}) + +describe('setProp', () => { + it('should reject with eror if redis.setex returns error', async () => { + const uuid = faker.random.uuid() + fst.addFailover(uuid, RedisStorageType.returnSet) + await expect(cacheExternal.setProp(uuid, uuid, 60)).rejects.toThrowError() + }) + + it('should reject with eror if redis.setex callbacks with error', async () => { + const uuid = faker.random.uuid() + fst.addFailover(uuid, RedisStorageType.callbackSet) + await expect(cacheExternal.setProp(uuid, uuid, 60)).rejects.toThrowError() + }) +}) + +describe('getProp', () => { + it('should reject with eror if redis.get returns error', async () => { + const uuid = faker.random.uuid() + fst.addFailover(uuid, RedisStorageType.returnGet) + await expect(cacheExternal.getProp(uuid)).rejects.toThrowError() + }) + + it('should reject with eror if redis.get callbacks with error', async () => { + const uuid = faker.random.uuid() + fst.addFailover(uuid, RedisStorageType.callbackGet) + await expect(cacheExternal.getProp(uuid)).rejects.toThrowError() + }) +}) + diff --git a/src/utils/cache_external.ts b/src/utils/cache_external.ts new file mode 100644 index 0000000..fc4c93c --- /dev/null +++ b/src/utils/cache_external.ts @@ -0,0 +1,88 @@ +import * as r from 'redis' + +import config from '@exmpl/config' +import logger from '@exmpl/utils/logger' + +const redis: typeof r = config.redisUrl === 'redis-mock' ? require('redis-mock') : require('redis') + +class Cache { + private static _instance: Cache + + private _client?: r.RedisClient + + private _initialConnection: boolean + + private constructor() { + this._initialConnection = true + } + + public static getInstance(): Cache { + if (!Cache._instance) { + Cache._instance = new Cache() + } + return Cache._instance + } + + public open(): Promise { + return new Promise((resolve, reject) => { + this._client = redis.createClient(config.redisUrl) + const client = this._client! + client.on('connect', () => { + logger.info('Redis: connected') + }) + client.on('ready', () => { + if (this._initialConnection) { + this._initialConnection = false + resolve() + } + logger.info('Redis: ready') + }) + client.on('reconnecting', () => { + logger.info('Redis: reconnecting') + }) + client.on('end', () => { + logger.info('Redis: end') + }) + client.on('disconnected', () => { + logger.error('Redis: disconnected') + }) + client.on('error', function(err) { + logger.error(`Redis: error: ${err}`) + }) + }) + } + + public close(): Promise { + return new Promise((resolve) => { + this._client!.quit(() => { + resolve() + }) + }) + } + + public setProp(key: string, value: string, expireAfter: number): Promise { + return new Promise((resolve, reject) => { + const result = this._client!.setex(key, expireAfter, value, function(error) { + if (error) return reject(error) + resolve() + }) + if (result !== undefined && result === false) { + reject(new Error('Redis connection error')) + } + }) + } + + public getProp(key: string): Promise { + return new Promise((resolve, reject) => { + const result = this._client!.get(key, function(error, result) { + if (error) return reject(error) + resolve(result ? result : undefined) + }) + if (result !== undefined && result === false) { + reject(new Error('Redis connection error')) + } + }) + } +} + +export default Cache.getInstance() diff --git a/yarn.lock b/yarn.lock index 6eb7786..31e25b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -718,6 +718,20 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== +"@types/redis-mock@^0.17.0": + version "0.17.0" + resolved "https://registry.yarnpkg.com/@types/redis-mock/-/redis-mock-0.17.0.tgz#13c56c8cf3f748ae1656efc2da9bd6a97cc38e4d" + integrity sha512-UDKHu9otOSE1fPjgn0H7UoggqVyuRYfo3WJpdXdVmzgGmr1XIM/dTk/gRYf/bLjIK5mxpV8inA5uNBS2sVOilA== + dependencies: + "@types/redis" "*" + +"@types/redis@*", "@types/redis@^2.8.27": + version "2.8.27" + resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.27.tgz#9bc89b472f3fc4a57a06c1823f2fc860c6c2fdf3" + integrity sha512-RRHarqPp3mgqHz+qzLVuQCJAIVaB3JBaczoj24QVVYu08wiCmB8vbOeNeK9lIH+pyT7+R/bbEPghAZZuhbZm0g== + dependencies: + "@types/node" "*" + "@types/serve-static@*": version "1.13.5" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.5.tgz#3d25d941a18415d3ab092def846e135a08bbcf53" @@ -4234,6 +4248,38 @@ readable-stream@^3.1.1, readable-stream@^3.4.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +redis-commands@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.6.0.tgz#36d4ca42ae9ed29815cdb30ad9f97982eba1ce23" + integrity sha512-2jnZ0IkjZxvguITjFTrGiLyzQZcTvaw8DAaCXxZq/dsHXz7KfMQ3OUJy7Tz9vnRtZRVz6VRCPDvruvU8Ts44wQ== + +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60= + +redis-mock@^0.52.0: + version "0.52.0" + resolved "https://registry.yarnpkg.com/redis-mock/-/redis-mock-0.52.0.tgz#4dabe306549abfd36305a3891d802e9839b2e22a" + integrity sha512-s+6m2BFfgbXdbN4Pbv/+y7/lLd5M7gCTuYI0EAdb0IqvqDjYoexh+fs3Gd1w2bYIchNAez11wDhbBlFxXnjUog== + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ= + dependencies: + redis-errors "^1.0.0" + +redis@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/redis/-/redis-3.0.2.tgz#bd47067b8a4a3e6a2e556e57f71cc82c7360150a" + integrity sha512-PNhLCrjU6vKVuMOyFu7oSP296mwBkcE6lrAjruBYG5LgdSqtRBoVQIylrMyVZD/lkF24RSNNatzvYag6HRBHjQ== + dependencies: + denque "^1.4.1" + redis-commands "^1.5.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + regex-not@^1.0.0, regex-not@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"