├── .eslintignore ├── .dockerignore ├── .env.temp ├── nodemon.json ├── src ├── common │ ├── utils │ │ ├── helper │ │ │ └── index.js │ │ └── workerProcesses │ │ │ └── index.js │ ├── errors │ │ └── index.js │ ├── constants │ │ └── index.js │ └── logging │ │ └── index.js ├── data │ ├── infrastructure │ │ └── db │ │ │ ├── schemas │ │ │ ├── index.js │ │ │ ├── Post.js │ │ │ └── User.js │ │ │ └── index.js │ ├── mapper │ │ └── index.js │ └── repositories │ │ ├── recourceLimiterRepository │ │ └── index.js │ │ ├── users │ │ └── index.js │ │ ├── authenticationRepository │ │ └── index.js │ │ └── posts │ │ └── index.js ├── swagger │ ├── paths │ │ ├── index.js │ │ ├── users │ │ │ └── index.js │ │ ├── auth │ │ │ └── index.js │ │ └── posts │ │ │ └── index.js │ ├── definitions │ │ ├── Token.js │ │ ├── index.js │ │ ├── Pagination.js │ │ ├── Post.js │ │ ├── User.js │ │ └── Errors.js │ ├── info │ │ └── index.js │ ├── components │ │ └── index.js │ └── index.js ├── presentation │ ├── http │ │ ├── routes │ │ │ ├── users │ │ │ │ ├── mapper.js │ │ │ │ ├── responses.js │ │ │ │ └── routes.js │ │ │ ├── auth │ │ │ │ └── routes.js │ │ │ └── posts │ │ │ │ └── routes.js │ │ ├── utils │ │ │ └── pagination.js │ │ ├── app.js │ │ └── middleware │ │ │ └── endpointValidator.js │ └── websockets │ │ └── index.js ├── signals │ └── index.js ├── configuration │ └── index.js ├── domain │ ├── posts │ │ ├── model.js │ │ └── service.js │ ├── users │ │ ├── model.js │ │ └── service.js │ ├── token │ │ └── model.js │ └── auth │ │ └── service.js └── server.js ├── .gitignore ├── .eslintrc.js ├── Dockerfile ├── tests ├── mockedData.js ├── domain │ └── services │ │ └── postService.test.js ├── data │ └── repositories │ │ └── postRepository.test.js └── presentation │ └── http │ └── routes │ └── posts.test.js ├── docker-compose.yml ├── .github └── workflows │ ├── running_tests_on_new_PR.yml │ └── running_tests_merging_on_master.yml ├── package.json ├── README.md └── LICENSE /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /.env.temp: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | HTTP_PORT= 3 | JWT_SECRET= 4 | REDIS_URL= -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["src/test/**"], 3 | "ext": "js,json" 4 | } 5 | -------------------------------------------------------------------------------- /src/common/utils/helper/index.js: -------------------------------------------------------------------------------- 1 | function getRetryAfterSeconds(msBeforeNext) { 2 | return Math.round(msBeforeNext / 1000) || 1; 3 | } 4 | 5 | module.exports = { 6 | getRetryAfterSeconds, 7 | }; 8 | -------------------------------------------------------------------------------- /src/data/infrastructure/db/schemas/index.js: -------------------------------------------------------------------------------- 1 | const { PostDao } = require('./Post'); 2 | const { UserDao } = require('./User'); 3 | 4 | module.exports.create = () => ({ 5 | Post: PostDao, 6 | User: UserDao, 7 | }); 8 | -------------------------------------------------------------------------------- /src/swagger/paths/index.js: -------------------------------------------------------------------------------- 1 | const auth = require('./auth'); 2 | const posts = require('./posts'); 3 | const users = require('./users'); 4 | 5 | module.exports = { 6 | auth, 7 | posts, 8 | users, 9 | }; 10 | -------------------------------------------------------------------------------- /src/swagger/definitions/Token.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'object', 3 | properties: { 4 | id: { 5 | type: 'string', 6 | }, 7 | expiresIn: { 8 | type: 'integer', 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/presentation/http/routes/users/mapper.js: -------------------------------------------------------------------------------- 1 | const UserResponse = require('./responses'); 2 | 3 | const toResponseModel = function toResponseModel(userDoc) { 4 | return new UserResponse({ ...userDoc }); 5 | }; 6 | 7 | module.exports = { 8 | toResponseModel, 9 | }; 10 | -------------------------------------------------------------------------------- /src/signals/index.js: -------------------------------------------------------------------------------- 1 | const process = require('process'); 2 | 3 | const init = (closeFunc) => async () => { 4 | try { 5 | await closeFunc(); 6 | process.exit(0); 7 | } catch (err) { 8 | process.exit(1); 9 | } 10 | }; 11 | 12 | module.exports = { init }; 13 | -------------------------------------------------------------------------------- /src/data/mapper/index.js: -------------------------------------------------------------------------------- 1 | const toDatabase = function toDatabase() { 2 | // TODO 3 | }; 4 | 5 | const toDomainModel = function toDomainModel(databaseDoc, DomainModel) { 6 | return new DomainModel(databaseDoc); 7 | }; 8 | 9 | module.exports = { 10 | toDatabase, 11 | toDomainModel, 12 | }; 13 | -------------------------------------------------------------------------------- /src/swagger/info/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | version: '1.0.0', 3 | title: 'NodeJS API for nodejs-api-showcase project', 4 | description: 'NodeJS API for nodejs-api-showcase project', 5 | license: { 6 | name: 'MIT', 7 | url: 'https://opensource.org/licenses/MIT', 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/configuration/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const config = { 4 | dbConnectionString: process.env.DATABASE_URL, 5 | httpPort: process.env.HTTP_PORT || 8080, 6 | jwtSecret: process.env.JWT_SECRET, 7 | redis: { 8 | uri: process.env.REDIS_URL, 9 | }, 10 | }; 11 | 12 | module.exports = config; 13 | -------------------------------------------------------------------------------- /src/swagger/definitions/index.js: -------------------------------------------------------------------------------- 1 | const Errors = require('./Errors'); 2 | const Pagination = require('./Pagination'); 3 | const Token = require('./Token'); 4 | const Post = require('./Post'); 5 | const User = require('./User'); 6 | 7 | module.exports = { 8 | Errors, 9 | Pagination, 10 | Token, 11 | Post, 12 | User, 13 | }; 14 | -------------------------------------------------------------------------------- /src/swagger/definitions/Pagination.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'object', 3 | properties: { 4 | limit: { 5 | type: 'integer', 6 | }, 7 | total: { 8 | type: 'integer', 9 | }, 10 | page: { 11 | type: 'integer', 12 | }, 13 | pages: { 14 | type: 'integer', 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/common/errors/index.js: -------------------------------------------------------------------------------- 1 | const httpErrors = require('throw-http-errors'); 2 | 3 | const isCustomError = (error) => { 4 | if (Object.keys(httpErrors).includes(error.name) || (error.status && Object.keys(httpErrors).includes(error.status.toString()))) { 5 | return true; 6 | } 7 | return false; 8 | }; 9 | 10 | module.exports = { 11 | 12 | ...httpErrors, 13 | isCustomError, 14 | }; 15 | -------------------------------------------------------------------------------- /src/swagger/definitions/Post.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'object', 3 | properties: { 4 | _id: { 5 | type: 'string', 6 | }, 7 | userId: { 8 | type: 'string', 9 | }, 10 | imageUrl: { 11 | type: 'integer', 12 | }, 13 | description: { 14 | type: 'string', 15 | }, 16 | publisher: { 17 | type: 'string', 18 | }, 19 | created: { 20 | type: 'string', 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /src/swagger/definitions/User.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'object', 3 | properties: { 4 | _id: { 5 | type: 'string', 6 | }, 7 | name: { 8 | type: 'string', 9 | }, 10 | surname: { 11 | type: 'string', 12 | }, 13 | username: { 14 | type: 'string', 15 | }, 16 | password: { 17 | type: 'string', 18 | }, 19 | email: { 20 | type: 'string', 21 | }, 22 | created: { 23 | type: 'string', 24 | }, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /src/presentation/websockets/index.js: -------------------------------------------------------------------------------- 1 | const socketIO = require('socket.io'); 2 | const logging = require('../../common/logging'); 3 | 4 | function create(httpServer) { 5 | const io = socketIO(httpServer); 6 | 7 | io.sockets.on('connection', (socket) => { 8 | socket.on('custom-event', (value) => { 9 | io.emit('custom-event', value); 10 | }); 11 | socket.on('disconnect', () => { 12 | logging.info('user disconnected'); 13 | }); 14 | }); 15 | } 16 | 17 | module.exports.init = create; 18 | -------------------------------------------------------------------------------- /src/domain/posts/model.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the app Model it is decoupled from 3 | * the Entities used for the databse 4 | */ 5 | class Post { 6 | constructor({ 7 | _id, 8 | userId, 9 | imageUrl, 10 | description, 11 | publisher, 12 | created, 13 | } = {}) { 14 | this.id = _id; 15 | this.userId = userId; 16 | this.imageUrl = imageUrl; 17 | this.description = description; 18 | this.publisher = publisher; 19 | this.created = created; 20 | } 21 | } 22 | 23 | module.exports = Post; 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | pids 11 | logs 12 | results 13 | 14 | npm-debug.log 15 | node_modules 16 | .idea/ 17 | .DS_Store 18 | 19 | # dotenv environment variables file 20 | .env 21 | .env.local 22 | .env.staging 23 | .env.stage 24 | .env.prod 25 | .local.env 26 | .test.env 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | # nyc test coverage 30 | .nyc_output 31 | # Compiled binary addons (http://nodejs.org/api/addons.html) 32 | build/Release 33 | dist/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'eslint-config-airbnb-base', 5 | ], 6 | parserOptions: { 7 | ecmaVersion: 2020, 8 | }, 9 | rules: { 10 | 'no-underscore-dangle': [2, { allowAfterThis: true }], 11 | 'class-methods-use-this': 0, 12 | strict: 0, 13 | 'max-len': 0, 14 | 'new-cap': ['error', { newIsCapExceptionPattern: '^errors\..' }], 15 | 'import/prefer-default-export': 'off', 16 | 'no-useless-constructor': 'off', 17 | 'import/extensions': 'off', 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/domain/users/model.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the app Model it is decoupled from 3 | * the Entities used for the databse 4 | */ 5 | class User { 6 | constructor({ 7 | _id, 8 | name, 9 | surname, 10 | username, 11 | email, 12 | password, 13 | created, 14 | } = {}) { 15 | this.id = _id; 16 | this.fullName = `${name} ${surname}`; 17 | this.username = username; 18 | this.email = email; 19 | this.password = password; 20 | this.created = created; 21 | } 22 | } 23 | 24 | module.exports = User; 25 | -------------------------------------------------------------------------------- /src/presentation/http/routes/users/responses.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the user response. 3 | * Added in order to avoid return password as response. 4 | * Password is property of our business model in domain layer. 5 | */ 6 | class UserResponse { 7 | constructor({ 8 | id, 9 | fullName, 10 | username, 11 | email, 12 | created, 13 | } = {}) { 14 | this.id = id; 15 | this.fullName = fullName; 16 | this.username = username; 17 | this.email = email; 18 | this.created = created; 19 | } 20 | } 21 | 22 | module.exports = UserResponse; 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine as builder 2 | RUN mkdir -p /build 3 | 4 | COPY ./package.json ./package-lock.json /build/ 5 | WORKDIR /build 6 | RUN npm ci 7 | 8 | # Bundle app source 9 | COPY . /build 10 | 11 | FROM node:16-alpine 12 | # user with username node is provided from the official node image 13 | ENV user node 14 | # Run the image as a non-root user 15 | USER $user 16 | 17 | # Create app directory 18 | RUN mkdir -p /home/$user/src 19 | WORKDIR /home/$user/src 20 | 21 | COPY --from=builder /build ./ 22 | 23 | EXPOSE 5555 24 | 25 | ENV NODE_ENV production 26 | 27 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /src/swagger/components/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | responses: { 3 | 401: { 4 | description: 'Unauthorized', 5 | schema: { 6 | $ref: '#/definitions/401', 7 | }, 8 | }, 9 | 400: { 10 | description: 'Bad request', 11 | schema: { 12 | $ref: '#/definitions/400', 13 | }, 14 | }, 15 | 404: { 16 | description: 'The resource was not found', 17 | schema: { 18 | $ref: '#/definitions/404', 19 | }, 20 | }, 21 | 500: { 22 | description: 'Internal Server Error', 23 | schema: { 24 | $ref: '#/definitions/500', 25 | }, 26 | }, 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /src/domain/users/service.js: -------------------------------------------------------------------------------- 1 | /* 2 | Here is the core of our application. Here we add our business logic. 3 | e.g. Lets say that every time that we ask for a user, we need his posts too. 4 | So we add this logic in domain layer. 5 | */ 6 | function init({ 7 | usersRepository, 8 | postsRepository, 9 | }) { 10 | async function getUser(options) { 11 | const [ 12 | user, 13 | posts, 14 | ] = await Promise.all([ 15 | usersRepository.getUser(options), 16 | postsRepository.listUserPosts(options), 17 | ]); 18 | return { 19 | user, 20 | posts, 21 | }; 22 | } 23 | 24 | return { 25 | getUser, 26 | }; 27 | } 28 | 29 | module.exports.init = init; 30 | -------------------------------------------------------------------------------- /tests/mockedData.js: -------------------------------------------------------------------------------- 1 | const posts = [ 2 | { 3 | userId: '5a510cf183fd9d0c74898e74', 4 | imageUrl: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQpeF6dmyApzNtT4UsGGtztb6ioOspen7pM8SAMRMIbY8gIeDh3', 5 | publisher: 'fb', 6 | }, 7 | { 8 | userId: '5a510cf183fd9d0c74898e74', 9 | imageUrl: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQpeF6dmyApzNtT4UsGGtztb6ioOspen7pM8SAMRMIbY8gIeDh3', 10 | publisher: 'fb', 11 | }, 12 | { 13 | userId: '5a5381ce26a3a8259070ae0f', 14 | imageUrl: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQpeF6dmyApzNtT4UsGGtztb6ioOspen7pM8SAMRMIbY8gIeDh3', 15 | publisher: 'twitter', 16 | }, 17 | ]; 18 | 19 | module.exports = { 20 | posts, 21 | }; 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | services: 3 | api-showcase: 4 | build: 5 | context: . 6 | container_name: nodejs-api-showcase 7 | ports: 8 | - "5555:5555" 9 | env_file: .env 10 | environment: 11 | - DATABASE_URL=mongodb://db:27017/nodejsshowcase 12 | depends_on: 13 | - db 14 | - redis 15 | volumes: 16 | - .:/home/nodejs/src 17 | db: 18 | image: mongo:latest 19 | ports: 20 | - "27017:27017" 21 | volumes: 22 | - mongodb:/data/db 23 | env_file: .env 24 | redis: 25 | image: redis:alpine 26 | command: ["redis-server", "--bind", "redis", "--port", "6379"] 27 | container_name: redis 28 | ports: 29 | - "6379:6379" 30 | restart: 31 | always 32 | volumes: 33 | mongodb: 34 | -------------------------------------------------------------------------------- /src/presentation/http/utils/pagination.js: -------------------------------------------------------------------------------- 1 | const { 2 | DEFAULT_PAGINATION_LIMIT, 3 | DEFAULT_PAGINATION_PAGE, 4 | MAX_PAGINATION_LIMIT, 5 | } = require('../../../common/constants'); 6 | 7 | function getDefaultLimit(limit) { 8 | if (limit == null) { 9 | return DEFAULT_PAGINATION_LIMIT; 10 | } 11 | if (isNaN(limit)) { 12 | return DEFAULT_PAGINATION_LIMIT; 13 | } 14 | if (Number(limit) > MAX_PAGINATION_LIMIT) { 15 | return MAX_PAGINATION_LIMIT; 16 | } 17 | return limit; 18 | } 19 | 20 | function getDefaultPage(page) { 21 | if (page == null) { 22 | return DEFAULT_PAGINATION_PAGE; 23 | } 24 | if (isNaN(page)) { 25 | return DEFAULT_PAGINATION_PAGE; 26 | } 27 | return page; 28 | } 29 | 30 | module.exports = { 31 | getDefaultLimit, 32 | getDefaultPage, 33 | }; 34 | -------------------------------------------------------------------------------- /src/common/constants/index.js: -------------------------------------------------------------------------------- 1 | const USER_TOKEN_EXPIRATION = 86400; 2 | const USER_ROLE = 'user'; 3 | const PASSWORD_COMPLEXITY = { 4 | min: 8, 5 | max: 35, 6 | lowerCase: 1, 7 | upperCase: 1, 8 | numeric: 1, 9 | symbol: 1, 10 | }; 11 | const MAX_CONSECUTIVE_FAILS_BY_USERNAME = 10; 12 | const PRODUCTION_ENV = 'production'; 13 | const VERBOSE_LOGGING_LVL = 'verbose'; 14 | const INFO_LOGGING_LVL = 'info'; 15 | const DEFAULT_PAGINATION_LIMIT = 25; 16 | const MAX_PAGINATION_LIMIT = 100; 17 | const DEFAULT_PAGINATION_PAGE = 1; 18 | 19 | module.exports = { 20 | USER_TOKEN_EXPIRATION, 21 | USER_ROLE, 22 | PASSWORD_COMPLEXITY, 23 | MAX_CONSECUTIVE_FAILS_BY_USERNAME, 24 | PRODUCTION_ENV, 25 | VERBOSE_LOGGING_LVL, 26 | INFO_LOGGING_LVL, 27 | DEFAULT_PAGINATION_LIMIT, 28 | MAX_PAGINATION_LIMIT, 29 | DEFAULT_PAGINATION_PAGE, 30 | }; 31 | -------------------------------------------------------------------------------- /src/domain/token/model.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the app Model it is decoupled from 3 | * the Entities used for the databse 4 | */ 5 | class Token { 6 | constructor({ 7 | accessToken, 8 | expiresIn, 9 | tokenType, 10 | roles, 11 | } = {}) { 12 | if (accessToken == null || typeof accessToken !== 'string') { 13 | throw new Error('accessToken should be a string'); 14 | } 15 | if (tokenType == null || typeof tokenType !== 'string') { 16 | throw new Error('tokenType should be a string'); 17 | } 18 | if (roles == null || !Array.isArray(roles) || roles.length <= 0) { 19 | throw new Error('roles should ben a array'); 20 | } 21 | this.accessToken = accessToken; 22 | this.expiresIn = expiresIn; 23 | this.tokenType = tokenType; 24 | this.roles = roles; 25 | } 26 | } 27 | 28 | module.exports = Token; 29 | -------------------------------------------------------------------------------- /src/data/infrastructure/db/schemas/Post.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const mongoosePaginate = require('mongoose-paginate'); 3 | const mongoose = require('mongoose'); 4 | 5 | const PostSchema = new mongoose.Schema({ 6 | userId: { 7 | type: mongoose.Schema.Types.ObjectId, 8 | ref: 'User', 9 | }, 10 | imageUrl: { 11 | type: String, 12 | required: true, 13 | }, 14 | description: String, 15 | publisher: { 16 | type: String, 17 | required: true, 18 | }, 19 | created: Date, 20 | }); 21 | 22 | PostSchema.index({ userId: 1 }); 23 | 24 | PostSchema.index({ created: -1 }); 25 | 26 | PostSchema.plugin(mongoosePaginate); 27 | 28 | PostSchema.pre('save', function (next) { 29 | this.created = moment().toJSON(); 30 | return next(); 31 | }); 32 | 33 | const PostDao = mongoose.model('Post', PostSchema); 34 | 35 | module.exports = { PostDao }; 36 | -------------------------------------------------------------------------------- /.github/workflows/running_tests_on_new_PR.yml: -------------------------------------------------------------------------------- 1 | name: '[Api_Showcase] Running Tests for new Pull Request' 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | jobs: 7 | tests: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: [14.x, 16.x] 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - name: Install dependencies server 19 | run: | # run multiple commands 20 | npm ci 21 | - name: Run tests server 22 | run: npm run test 23 | env: 24 | APP_ENV: test 25 | DATABASE_URL: ${{ secrets.DATABASE_URL_TEST }} 26 | HTTP_PORT: 8080 27 | JWT_SECRET: ${{ secrets.JWT_SECRET_TEST }} 28 | REDIS_URL: ${{ secrets.REDIS_URL }} 29 | -------------------------------------------------------------------------------- /.github/workflows/running_tests_merging_on_master.yml: -------------------------------------------------------------------------------- 1 | name: '[Api_Showcase] Running Tests on merging to Master Branch' 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [14.x, 16.x] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: Install dependencies server 20 | run: | # run multiple commands 21 | npm ci 22 | - name: Run tests server 23 | run: npm run test 24 | env: 25 | APP_ENV: test 26 | DATABASE_URL: ${{ secrets.DATABASE_URL_TEST }} 27 | HTTP_PORT: 8080 28 | JWT_SECRET: ${{ secrets.JWT_SECRET_TEST }} 29 | REDIS_URL: ${{ secrets.REDIS_URL }} -------------------------------------------------------------------------------- /src/presentation/http/routes/users/routes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const asyncWrapper = require('@dimosbotsaris/express-async-handler'); 3 | const { 4 | validateUserToken, 5 | } = require('../../middleware/endpointValidator'); 6 | const { 7 | toResponseModel, 8 | } = require('./mapper'); 9 | const postsRouter = require('../posts/routes'); 10 | 11 | // eslint-disable-next-line new-cap 12 | const router = express.Router({ mergeParams: true }); 13 | 14 | function init({ usersService, postsService }) { 15 | router.get( 16 | '/:userId', 17 | validateUserToken(), 18 | asyncWrapper(async (req, res) => { 19 | const result = await usersService.getUser({ 20 | userId: req.params.userId, 21 | }); 22 | return res.send({ 23 | data: { 24 | ...toResponseModel(result.user), 25 | posts: result.posts, 26 | }, 27 | }); 28 | }), 29 | ); 30 | 31 | router.use('/:userId/posts', postsRouter.init({ 32 | postsService, 33 | })); 34 | 35 | return router; 36 | } 37 | 38 | module.exports.init = init; 39 | -------------------------------------------------------------------------------- /src/swagger/paths/users/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'get-users': { 3 | parameters: [ 4 | { 5 | name: 'userId', 6 | in: 'path', 7 | required: true, 8 | description: 'Id of user', 9 | type: 'string', 10 | }, 11 | ], 12 | get: { 13 | tags: [ 14 | 'Users', 15 | ], 16 | security: [ 17 | { 18 | Bearer: [], 19 | }, 20 | ], 21 | summary: 'Get specific user', 22 | responses: { 23 | 200: { 24 | description: 'User is found in db', 25 | schema: { 26 | type: 'object', 27 | properties: { 28 | data: { 29 | type: 'object', 30 | $ref: '#/definitions/User', 31 | }, 32 | }, 33 | }, 34 | }, 35 | 400: { 36 | $ref: '#/components/responses/400', 37 | }, 38 | 401: { 39 | $ref: '#/components/responses/401', 40 | }, 41 | 404: { 42 | $ref: '#/components/responses/404', 43 | }, 44 | 500: { 45 | $ref: '#/components/responses/500', 46 | }, 47 | }, 48 | }, 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /src/domain/posts/service.js: -------------------------------------------------------------------------------- 1 | // DOMAIN LAYER 2 | // Has the postRepository as a dependency. The PostService does not know 3 | // nor does it care where the post models came from. This is abstracted away 4 | // by the implementation of the repositories. It just calls the needed repositories 5 | // gets the results and usually applies some business logic on them. 6 | function init({ 7 | postsRepository, 8 | }) { 9 | async function listUserPosts({ 10 | userId, 11 | publisher, 12 | limit, 13 | page, 14 | }) { 15 | return postsRepository.listUserPosts({ 16 | userId, 17 | publisher, 18 | limit, 19 | page, 20 | }); 21 | } 22 | 23 | async function createUserPost({ 24 | userId, 25 | imageUrl, 26 | description, 27 | publisher, 28 | }) { 29 | return postsRepository.createUserPost({ 30 | userId, 31 | imageUrl, 32 | description, 33 | publisher, 34 | }); 35 | } 36 | 37 | async function getUserPost({ 38 | userId, 39 | postId, 40 | }) { 41 | return postsRepository.getUserPost({ 42 | userId, 43 | postId, 44 | }); 45 | } 46 | 47 | return { 48 | listUserPosts, 49 | createUserPost, 50 | getUserPost, 51 | }; 52 | } 53 | 54 | module.exports.init = init; 55 | -------------------------------------------------------------------------------- /src/data/repositories/recourceLimiterRepository/index.js: -------------------------------------------------------------------------------- 1 | const Redis = require('ioredis'); 2 | const { RateLimiterRedis } = require('rate-limiter-flexible'); 3 | const { 4 | redis: redisConfig, 5 | } = require('../../../configuration'); 6 | const { 7 | MAX_CONSECUTIVE_FAILS_BY_USERNAME, 8 | } = require('../../../common/constants'); 9 | 10 | module.exports.init = function init() { 11 | const redisClient = new Redis(redisConfig.uri, { enableOfflineQueue: false }); 12 | 13 | const limiterUserConsecutiveFailsByUsername = new RateLimiterRedis({ 14 | storeClient: redisClient, 15 | keyPrefix: 'login_fail_consecutive_username_user', 16 | points: MAX_CONSECUTIVE_FAILS_BY_USERNAME, 17 | duration: 60 * 10, // Store number for 10 minutes since first fail(ttl) 18 | blockDuration: 60 * 10, // Block for 10mnts 19 | }); 20 | 21 | return { 22 | maxConsecutiveFailsByUsername: MAX_CONSECUTIVE_FAILS_BY_USERNAME, 23 | getUserKeyForFailedLogin: async (usernameKey) => limiterUserConsecutiveFailsByUsername.get(usernameKey), 24 | consumeUserPointsForFailedLogin: async (usernameKey) => limiterUserConsecutiveFailsByUsername.consume(usernameKey), 25 | deleteUserKeyForFailedLogin: async (usernameKey) => limiterUserConsecutiveFailsByUsername.delete(usernameKey), 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/data/infrastructure/db/schemas/User.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const bcrypt = require('bcryptjs'); 3 | const uniqueValidator = require('mongoose-unique-validator'); 4 | const { 5 | model, 6 | Schema, 7 | } = require('mongoose'); 8 | 9 | const UserSchema = new Schema({ 10 | name: { 11 | type: String, 12 | required: true, 13 | }, 14 | surname: { 15 | type: String, 16 | required: true, 17 | }, 18 | username: { 19 | type: String, 20 | required: true, 21 | }, 22 | password: { 23 | type: String, 24 | required: true, 25 | }, 26 | email: { 27 | type: String, 28 | required: true, 29 | unique: true, 30 | }, 31 | created: Date, 32 | }); 33 | 34 | UserSchema.index({ name: 1 }); 35 | 36 | UserSchema.index({ name: 1, created: -1 }); 37 | 38 | UserSchema.plugin(uniqueValidator); 39 | 40 | UserSchema.pre('save', function (next) { 41 | bcrypt.genSalt(10, (err, salt) => { 42 | if (err) { 43 | return next(err); 44 | } 45 | bcrypt.hash(this.password, salt, (error, hash) => { 46 | if (error) { 47 | return next(error); 48 | } 49 | this.password = hash; 50 | this.created = moment().toJSON(); 51 | return next(); 52 | }); 53 | }); 54 | }); 55 | 56 | const UserDao = model('User', UserSchema); 57 | 58 | module.exports = { UserDao }; 59 | -------------------------------------------------------------------------------- /src/data/infrastructure/db/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | It can be published as private npm module shared among all team's projects. 3 | */ 4 | const mongoose = require('mongoose'); 5 | const schemas = require('./schemas'); 6 | const logging = require('../../../common/logging'); 7 | 8 | module.exports.init = (dbConnectionString) => { 9 | if (!dbConnectionString) { 10 | throw new Error('add correct format of config with dbConnectionString'); 11 | } 12 | // Check for errors on connecting to Mongo DB 13 | mongoose.connection.on('error', (err) => { 14 | logging.error(`Error! DB Connection failed. Error: ${err}`); 15 | return err; 16 | }); 17 | // Connection opened successfully 18 | mongoose.connection.once('open', () => { 19 | logging.info('Connection to MongoDB established'); 20 | // mongoose.connection.db.dropDatabase() 21 | }); 22 | mongoose.connection.on('disconnected', () => { 23 | logging.info('Connection to MongoDB closed'); 24 | logging.info('-------------------'); 25 | }); 26 | 27 | return { 28 | getConnection() { 29 | return mongoose.connection; 30 | }, 31 | connect() { 32 | // Open Connection to Mongo DB 33 | return mongoose.connect(dbConnectionString); 34 | }, 35 | close() { 36 | return mongoose.connection.close(); 37 | }, 38 | schemas: schemas.create(), 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /src/common/logging/index.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | const expressWinston = require('express-winston'); 3 | const { 4 | PRODUCTION_ENV, 5 | VERBOSE_LOGGING_LVL, 6 | INFO_LOGGING_LVL, 7 | } = require('../constants'); 8 | 9 | const getTransports = () => { 10 | const transports = [ 11 | new winston.transports.Console(), 12 | ]; 13 | return transports; 14 | }; 15 | 16 | const getFormat = () => winston.format.combine( 17 | winston.format.colorize(), 18 | winston.format.json(), 19 | ); 20 | 21 | const requestLogger = expressWinston.logger({ 22 | transports: getTransports(), 23 | format: getFormat(), 24 | colorize: true, 25 | expressFormat: true, 26 | meta: true, 27 | }); 28 | 29 | const errorLogger = expressWinston.errorLogger({ 30 | transports: getTransports(), 31 | format: getFormat(), 32 | }); 33 | 34 | const logger = winston.createLogger({ 35 | level: process.env.NODE_ENV !== PRODUCTION_ENV ? VERBOSE_LOGGING_LVL : INFO_LOGGING_LVL, 36 | format: getFormat(), 37 | transports: getTransports(), 38 | }); 39 | 40 | module.exports = { 41 | requestLogger, 42 | errorLogger, 43 | raw: logger, 44 | error: logger.error.bind(logger), 45 | warn: logger.warn.bind(logger), 46 | info: logger.info.bind(logger), 47 | log: logger.log.bind(logger), 48 | verbose: logger.verbose.bind(logger), 49 | debug: logger.debug.bind(logger), 50 | silly: logger.silly.bind(logger), 51 | }; 52 | -------------------------------------------------------------------------------- /src/presentation/http/routes/auth/routes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const asyncWrapper = require('@dimosbotsaris/express-async-handler'); 3 | const { 4 | validateLoginBodyParams, 5 | validateCreateUserBody, 6 | } = require('../../middleware/endpointValidator'); 7 | const { 8 | toResponseModel, 9 | } = require('../users/mapper'); 10 | 11 | // eslint-disable-next-line new-cap 12 | const router = express.Router({ mergeParams: true }); 13 | 14 | function init({ authService }) { 15 | router.post( 16 | '/register', 17 | validateCreateUserBody(), 18 | asyncWrapper(async (req, res) => { 19 | const result = await authService.register({ 20 | name: req.body.name, 21 | surname: req.body.surname, 22 | username: req.body.username, 23 | email: req.body.email, 24 | password: req.body.password, 25 | }); 26 | return res.send({ 27 | data: toResponseModel(result), 28 | }); 29 | }), 30 | ); 31 | 32 | router.post( 33 | '/login', 34 | validateLoginBodyParams(), 35 | asyncWrapper(async (req, res) => { 36 | const result = await authService.login({ 37 | email: req.body.email, 38 | password: req.body.password, 39 | }); 40 | return res.send({ 41 | data: { 42 | token: result.token, 43 | user: toResponseModel(result.user), 44 | }, 45 | }); 46 | }), 47 | ); 48 | 49 | return router; 50 | } 51 | 52 | module.exports.init = init; 53 | -------------------------------------------------------------------------------- /src/data/repositories/users/index.js: -------------------------------------------------------------------------------- 1 | const errors = require('../../../common/errors'); 2 | const mapper = require('../../mapper'); 3 | const UserDomainModel = require('../../../domain/users/model'); 4 | 5 | const queryForGetUser = ({ email, userId }) => { 6 | const queries = {}; 7 | if (userId) { 8 | queries._id = userId; 9 | } 10 | if (email) { 11 | queries.email = email; 12 | } 13 | return queries; 14 | }; 15 | 16 | const userStore = { 17 | async createUser({ 18 | name, 19 | surname, 20 | username, 21 | email, 22 | password, 23 | }) { 24 | const { 25 | User: userSchema, 26 | } = this.getSchemas(); 27 | const newUser = new userSchema({ 28 | name, 29 | surname, 30 | username, 31 | email, 32 | password, 33 | }); 34 | const userDoc = await newUser.save(); 35 | return mapper.toDomainModel(userDoc, UserDomainModel); 36 | }, 37 | 38 | async getUser({ 39 | email, 40 | userId, 41 | }) { 42 | const { User: userSchema } = this.getSchemas(); 43 | const userDoc = await userSchema.findOne(queryForGetUser({ 44 | email, 45 | userId, 46 | })) 47 | .lean() 48 | .exec(); 49 | if (!userDoc) { 50 | throw new errors.NotFound('User not found.'); 51 | } 52 | return mapper.toDomainModel(userDoc, UserDomainModel); 53 | }, 54 | }; 55 | 56 | module.exports.init = function init({ User }) { 57 | return Object.assign(Object.create(userStore), { 58 | getSchemas() { 59 | return { 60 | User, 61 | }; 62 | }, 63 | }); 64 | }; 65 | -------------------------------------------------------------------------------- /src/presentation/http/app.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const express = require('express'); 3 | const cors = require('cors'); 4 | const compress = require('compression')(); 5 | const bodyParser = require('body-parser'); 6 | const logger = require('morgan'); 7 | const helmet = require('helmet'); 8 | const path = require('path'); 9 | const swaggerUi = require('swagger-ui-express'); 10 | const { errorHandler } = require('@dimosbotsaris/express-error-handler'); 11 | const jwt = require('express-jwt'); 12 | const authRoutes = require('./routes/auth/routes'); 13 | const usersRoutes = require('./routes/users/routes'); 14 | const swaggerDocument = require('../../swagger'); 15 | const { 16 | jwtSecret, 17 | } = require('../../configuration'); 18 | 19 | const app = express(); 20 | app.disable('x-powered-by'); 21 | app.use(helmet()); 22 | app.use(bodyParser.urlencoded({ extended: false })); 23 | app.use(bodyParser.json({ limit: '5mb' })); 24 | app.use(compress); 25 | app.use(logger('dev')); 26 | app.use(cors()); 27 | 28 | module.exports.init = (services) => { 29 | app.use(express.static(path.join(__dirname, 'public'))); 30 | // swagger API docs 31 | app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument, { 32 | explorer: true, 33 | })); 34 | app.use(jwt({ 35 | secret: jwtSecret, 36 | algorithms: ['HS256'], 37 | }) 38 | .unless({ 39 | path: ['/auth/register', '/auth/login'], 40 | })); 41 | app.use('/auth', authRoutes.init(services)); 42 | app.use('/users', usersRoutes.init(services)); 43 | app.use(errorHandler({ trace: true })); 44 | const httpServer = http.createServer(app); 45 | return httpServer; 46 | }; 47 | -------------------------------------------------------------------------------- /tests/domain/services/postService.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const sinon = require('sinon'); 3 | const Post = require('../../../src/domain/posts/model'); 4 | const postServiceFactory = require('../../../src/domain/posts/service'); 5 | const postRepositoryFactory = require('../../../src/data/repositories/posts'); 6 | 7 | const db = sinon.stub(); 8 | 9 | const postRepository = postRepositoryFactory.init(db); 10 | const postService = postServiceFactory.init(postRepository); 11 | 12 | function createPosts() { 13 | const alex = new Post({ 14 | imageUrl: 'www.test.com', description: 'test1', publisher: 'Aris', created: '2017-08-30T08:17:50.460Z', _id: '5a3b9a95e9f13308a30740a5', 15 | }); 16 | const aris = new Post({ 17 | imageUrl: 'www.test1.com', description: 'test2', publisher: 'Alex', created: '2017-08-30T08:17:50.460Z', _id: 'testid2', 18 | }); 19 | return [alex, aris]; 20 | } 21 | 22 | // eslint-disable-next-line no-undef 23 | describe('post service test', () => { 24 | // eslint-disable-next-line no-undef 25 | beforeEach(() => { 26 | sinon.stub(postService, 'listUserPosts'); 27 | sinon.stub(postService, 'getUserPost'); 28 | }); 29 | // eslint-disable-next-line no-undef 30 | afterEach(() => { 31 | postService.listUserPosts.restore(); 32 | postService.getUserPost.restore(); 33 | }); 34 | // eslint-disable-next-line no-undef 35 | it('should call the repository to list user posts using listUserPosts function', (done) => { 36 | postService.listUserPosts.resolves(createPosts()); 37 | postService.listUserPosts() 38 | .then((posts) => { 39 | expect(posts).to.have.lengthOf(2); 40 | expect(posts).to.eql(createPosts()); 41 | return done(); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/presentation/http/routes/posts/routes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const asyncWrapper = require('@dimosbotsaris/express-async-handler'); 3 | const { 4 | validateUserToken, 5 | validatePostId, 6 | validateCreatePostBody, 7 | } = require('../../middleware/endpointValidator'); 8 | const { 9 | getDefaultPage, 10 | getDefaultLimit, 11 | } = require('../../utils/pagination'); 12 | 13 | // eslint-disable-next-line new-cap 14 | const router = express.Router({ mergeParams: true }); 15 | 16 | function init({ 17 | postsService, 18 | }) { 19 | router.get( 20 | '/', 21 | validateUserToken(), 22 | asyncWrapper(async (req, res) => { 23 | const postsList = await postsService.listUserPosts({ 24 | userId: req.params.userId, 25 | publisher: req.query.publisher, 26 | page: getDefaultPage(parseInt(req.query.page, 10)), 27 | limit: getDefaultLimit(parseInt(req.query.limit, 10)), 28 | }); 29 | return res.send(postsList); 30 | }), 31 | ); 32 | 33 | router.post( 34 | '/', 35 | validateUserToken(), 36 | validateCreatePostBody(), 37 | asyncWrapper(async (req, res) => { 38 | const newPost = await postsService.createUserPost({ 39 | imageUrl: req.body.imageUrl, 40 | description: req.body.description, 41 | publisher: req.body.publisher, 42 | userId: req.params.userId, 43 | }); 44 | return res.send({ 45 | data: newPost, 46 | }); 47 | }), 48 | ); 49 | 50 | router.get( 51 | '/:postId', 52 | validateUserToken(), 53 | validatePostId(), 54 | asyncWrapper(async (req, res) => { 55 | const postDoc = await postsService.getUserPost({ 56 | postId: req.params.postId, 57 | userId: req.params.userId, 58 | }); 59 | return res.send({ 60 | data: postDoc, 61 | }); 62 | }), 63 | ); 64 | 65 | return router; 66 | } 67 | 68 | module.exports.init = init; 69 | -------------------------------------------------------------------------------- /tests/data/repositories/postRepository.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const sinon = require('sinon'); 3 | const schemasFactory = require('../../../src/data/infrastructure/db/schemas'); 4 | const { 5 | posts: postDocs, 6 | } = require('../../mockedData'); 7 | 8 | const schemas = schemasFactory.create(); 9 | const db = { 10 | schemas, 11 | }; 12 | const postRepositoryContainer = require('../../../src/data/repositories/posts'); 13 | 14 | const postRepository = postRepositoryContainer.init(db.schemas); 15 | 16 | function createDbPosts(total = []) { 17 | return () => { 18 | const doc = new db.schemas.Post(postDocs.pop()); 19 | total.push(doc); 20 | if (postDocs.length > 0) { 21 | return createDbPosts(total)(); 22 | } 23 | return total; 24 | }; 25 | } 26 | 27 | // eslint-disable-next-line no-undef 28 | describe('post repository test', () => { 29 | // eslint-disable-next-line no-undef 30 | beforeEach(() => { 31 | sinon.stub(db.schemas.Post, 'paginate'); 32 | sinon.stub(db.schemas.Post, 'findOne'); 33 | }); 34 | // eslint-disable-next-line no-undef 35 | afterEach(() => { 36 | db.schemas.Post.paginate.restore(); 37 | db.schemas.Post.findOne.restore(); 38 | }); 39 | // eslint-disable-next-line no-undef 40 | describe('post listUserPosts method', () => { 41 | // eslint-disable-next-line no-undef 42 | it('should call the db and return list of posts', async () => { 43 | const posts = createDbPosts([])(); 44 | db.schemas.Post.paginate.resolves({ 45 | docs: posts, 46 | page: 1, 47 | limit: 15, 48 | pages: 1, 49 | total: 3, 50 | }); 51 | const response = await postRepository.listUserPosts({ 52 | page: 1, 53 | limit: 15, 54 | }); 55 | expect(response.data).to.have.lengthOf(3); 56 | expect(response.pagination.total).to.eql(3); 57 | expect(response.pagination.limit).to.eql(15); 58 | expect(response.pagination.page).to.eql(1); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/swagger/definitions/Errors.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 401: { 3 | type: 'object', 4 | properties: { 5 | status: { 6 | type: 'integer', 7 | default: 401, 8 | }, 9 | data: { 10 | type: 'object', 11 | properties: { 12 | code: { 13 | type: 'integer', 14 | default: 401, 15 | }, 16 | message: { 17 | type: 'string', 18 | default: 'Invalid user token', 19 | }, 20 | }, 21 | }, 22 | }, 23 | }, 24 | 404: { 25 | type: 'object', 26 | properties: { 27 | status: { 28 | type: 'integer', 29 | default: 404, 30 | }, 31 | data: { 32 | type: 'object', 33 | properties: { 34 | code: { 35 | type: 'integer', 36 | default: 404, 37 | }, 38 | message: { 39 | type: 'string', 40 | default: 'The resource was not found', 41 | }, 42 | }, 43 | }, 44 | }, 45 | }, 46 | 400: { 47 | type: 'object', 48 | properties: { 49 | status: { 50 | type: 'integer', 51 | default: 400, 52 | }, 53 | data: { 54 | type: 'object', 55 | properties: { 56 | code: { 57 | type: 'integer', 58 | default: 400, 59 | }, 60 | message: { 61 | type: 'string', 62 | default: 'Bad request', 63 | }, 64 | }, 65 | }, 66 | }, 67 | }, 68 | 500: { 69 | type: 'object', 70 | properties: { 71 | status: { 72 | type: 'integer', 73 | default: 500, 74 | }, 75 | data: { 76 | type: 'object', 77 | properties: { 78 | code: { 79 | type: 'integer', 80 | default: 500, 81 | }, 82 | message: { 83 | type: 'string', 84 | default: 'Internal Server Error', 85 | }, 86 | }, 87 | }, 88 | }, 89 | }, 90 | }; 91 | -------------------------------------------------------------------------------- /src/common/utils/workerProcesses/index.js: -------------------------------------------------------------------------------- 1 | const cluster = require('cluster'); 2 | const os = require('os'); 3 | const logging = require('../../logging'); 4 | /** 5 | * Setup number of worker processes to share port which will be defined while setting up server 6 | */ 7 | const setupWorkerProcesses = () => { 8 | // to read number of cores on system 9 | const numCores = os.cpus().length; 10 | const workers = []; 11 | logging.info(`Master cluster setting up ${numCores} workers`); 12 | 13 | // iterate on number of cores need to be utilized by an application 14 | // current example will utilize all of them 15 | for (let i = 0; i < numCores; i++) { 16 | // creating workers and pushing reference in an array 17 | // these references can be used to receive messages from workers 18 | workers.push(cluster.fork()); 19 | // to receive messages from worker process 20 | workers[i].on('message', (message) => logging.info(`worker message: ${message}`)); 21 | } 22 | // process is clustered on a core and process id is assigned 23 | cluster.on('online', (worker) => logging.info(`Worker ${worker.process.pid} is online`)); 24 | cluster.on('listening', (worker) => logging.info(`Worker ${worker.process.pid} is listening`)); 25 | // if any of the worker process dies then start a new one by simply forking another one 26 | cluster.on('exit', (worker, code, signal) => { 27 | logging.info(`Worker ${worker.process.pid} died with code: ${code} and signal: ${signal}`); 28 | // if condition above to make sure the worker process actually crashed and was not manually disconnected or killed by the master process itself. 29 | if (code !== 0 && !worker.exitedAfterDisconnect) { 30 | logging.info('Starting a new worker'); 31 | const newWorker = cluster.fork(); 32 | workers.push(newWorker); 33 | // to receive messages from new worker process 34 | workers[workers.length - 1].on('message', (message) => logging.info(`worker message: ${message}`)); 35 | } 36 | }); 37 | }; 38 | 39 | module.exports = setupWorkerProcesses; 40 | -------------------------------------------------------------------------------- /src/swagger/index.js: -------------------------------------------------------------------------------- 1 | const definitions = require('./definitions'); 2 | const info = require('./info'); 3 | const paths = require('./paths'); 4 | const components = require('./components'); 5 | 6 | let hostUrl = 'PROD_API_URL'; 7 | 8 | if (!process.env.NODE_ENV) { 9 | hostUrl = 'localhost:8080'; 10 | } 11 | 12 | module.exports = { 13 | swagger: '2.0', 14 | info, 15 | host: hostUrl, 16 | basePath: '/', 17 | tags: [ 18 | { 19 | name: 'Users', 20 | description: 'Endpoints for users endpoints', 21 | }, 22 | { 23 | name: 'Auth', 24 | description: 'Endpoints for register/login', 25 | }, 26 | { 27 | name: 'Posts', 28 | description: 'Endpoints for posts', 29 | }, 30 | ], 31 | schemes: [ 32 | 'http', 33 | 'https', 34 | ], 35 | consumes: [ 36 | 'application/json', 37 | ], 38 | produces: [ 39 | 'application/json', 40 | ], 41 | securityDefinitions: { 42 | Bearer: { 43 | description: 44 | `For accessing the API a valid JWT token must be passed in all the queries in 45 | the 'Authorization' header. 46 | A valid JWT token is generated by the expressJwt and returned on dashboard, after a 47 | succefull login. It is stored as id_token in localstorage. 48 | 49 | The following syntax must be used in the 'Authorization' header: 50 | 51 | Bearer xxxxxx.yyyyyyy.zzzzzz`, 52 | type: 'apiKey', 53 | name: 'Authorization', 54 | in: 'header', 55 | }, 56 | }, 57 | paths: { 58 | '/register/': paths.auth.register, 59 | '/login': paths.auth.login, 60 | '/posts': paths.posts['list-posts'], 61 | '/posts/': paths.posts['post-posts'], 62 | '/posts/{postId}/': paths.posts['get-posts'], 63 | '/users/{userId}': paths.users['get-users'], 64 | }, 65 | components, 66 | definitions: { 67 | Pagination: definitions.Pagination, 68 | Token: definitions.Token, 69 | Post: definitions.Post, 70 | User: definitions.User, 71 | 401: definitions.Errors[401], 72 | 400: definitions.Errors[400], 73 | 404: definitions.Errors[404], 74 | 500: definitions.Errors[500], 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /src/data/repositories/authenticationRepository/index.js: -------------------------------------------------------------------------------- 1 | const util = require('util'); 2 | const jwt = require('jsonwebtoken'); 3 | const bcrypt = require('bcryptjs'); 4 | const mapper = require('../../mapper'); 5 | const logging = require('../../../common/logging'); 6 | const { 7 | USER_TOKEN_EXPIRATION, 8 | USER_ROLE, 9 | } = require('../../../common/constants'); 10 | const { 11 | jwtSecret, 12 | } = require('../../../configuration'); 13 | const DomainToken = require('../../../domain/token/model'); 14 | const errors = require('../../../common/errors'); 15 | 16 | const SALT_ROUNDS = 10; 17 | const genSalt = util.promisify(bcrypt.genSalt); 18 | const hash = util.promisify(bcrypt.hash); 19 | 20 | module.exports.init = function init() { 21 | async function comparePassword(password, dbPassword) { 22 | try { 23 | const match = await bcrypt.compare(password, dbPassword); 24 | if (!match) { 25 | throw new Error('Authentication error'); 26 | } 27 | return match; 28 | } catch (error) { 29 | throw new errors.Unauthorized('Wrong password.'); 30 | } 31 | } 32 | 33 | async function hashPassword(password) { 34 | const salt = await genSalt(SALT_ROUNDS); 35 | return hash(password, salt); 36 | } 37 | 38 | async function createUserToken(user) { 39 | logging.info('Create consultant token called'); 40 | const token = { 41 | accessToken: jwt.sign({ 42 | email: user.email, 43 | fullName: user.fullName, 44 | _id: user.id, 45 | roles: [USER_ROLE], 46 | }, jwtSecret, { 47 | expiresIn: USER_TOKEN_EXPIRATION, 48 | }), 49 | tokenType: 'Bearer', 50 | roles: [USER_ROLE], 51 | expiresIn: USER_TOKEN_EXPIRATION, 52 | }; 53 | return mapper.toDomainModel(token, DomainToken); 54 | } 55 | 56 | async function verifyToken(token, secret) { 57 | return jwt.verify(token, secret); 58 | } 59 | 60 | function verifyTokenSync(token, secret) { 61 | return jwt.verify(token, secret); 62 | } 63 | 64 | return { 65 | createUserToken, 66 | verifyToken, 67 | verifyTokenSync, 68 | comparePassword, 69 | hashPassword, 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejs-api-showcase", 3 | "version": "1.1.1", 4 | "description": "", 5 | "main": "node src/server.js", 6 | "scripts": { 7 | "coverage": "nyc --reporter html --reporter text mocha test --recursive", 8 | "debug": "nodemon src/server.js", 9 | "start": "node src/server.js", 10 | "test": "mocha tests --recursive --timeout 15000 --exit", 11 | "lint": "eslint --fix ." 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/eldimious/nodejs-api-showcase.git" 16 | }, 17 | "author": "Dimos Botsaris ", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/eldimious/nodejs-api-showcase/issues" 21 | }, 22 | "homepage": "https://github.com/eldimious/nodejs-api-showcase#readme", 23 | "dependencies": { 24 | "@dimosbotsaris/express-async-handler": "^1.0.2", 25 | "@dimosbotsaris/express-error-handler": "^1.0.2", 26 | "@hapi/joi": "^17.1.1", 27 | "bcryptjs": "^2.4.3", 28 | "body-parser": "^1.19.1", 29 | "compression": "^1.7.4", 30 | "cors": "^2.8.5", 31 | "dotenv": "^14.2.0", 32 | "express": "^4.17.2", 33 | "express-jwt": "^6.1.0", 34 | "express-validator": "^6.14.0", 35 | "express-winston": "^4.2.0", 36 | "helmet": "^5.0.1", 37 | "ioredis": "^4.28.3", 38 | "joi-password-complexity": "^4.1.0", 39 | "jsonwebtoken": "^8.5.1", 40 | "moment": "^2.29.2", 41 | "mongodb": "^4.3.1", 42 | "mongoose": "^6.1.7", 43 | "mongoose-paginate": "^5.0.3", 44 | "mongoose-unique-validator": "^3.0.0", 45 | "morgan": "^1.10.0", 46 | "rate-limiter-flexible": "^2.3.6", 47 | "socket.io": "^4.4.1", 48 | "swagger-ui-express": "^4.3.0", 49 | "throw-http-errors": "2.0.0", 50 | "winston": "^3.4.0" 51 | }, 52 | "devDependencies": { 53 | "chai": "^4.1.2", 54 | "coveralls": "^3.0.2", 55 | "eslint": "^8.7.0", 56 | "eslint-config-airbnb-base": "^15.0.0", 57 | "eslint-plugin-import": "^2.25.4", 58 | "istanbul": "^0.4.5", 59 | "mocha": "^9.1.3", 60 | "nodemon": "^2.0.15", 61 | "nyc": "^15.1.0", 62 | "sinon": "^4.0.0", 63 | "supertest": "^3.0.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | const cluster = require('cluster'); 2 | const { 3 | httpPort, 4 | dbConnectionString, 5 | } = require('./configuration'); 6 | const setupWorkerProcesses = require('./common/utils/workerProcesses'); 7 | const logging = require('./common/logging'); 8 | const signals = require('./signals'); 9 | const dbContainer = require('./data/infrastructure/db'); 10 | const postsRepositoryContainer = require('./data/repositories/posts'); 11 | const usersRepositoryContainer = require('./data/repositories/users'); 12 | const authenticationRepositoryContainer = require('./data/repositories/authenticationRepository'); 13 | const recourceLimiterRepositoryContainer = require('./data/repositories/recourceLimiterRepository'); 14 | const authServiceContainer = require('./domain/auth/service'); 15 | const postsServiceContainer = require('./domain/posts/service'); 16 | const usersServiceContainer = require('./domain/users/service'); 17 | const appContainer = require('./presentation/http/app'); 18 | const websocketsContainer = require('./presentation/websockets'); 19 | 20 | const db = dbContainer.init(dbConnectionString); 21 | const authenticationRepository = authenticationRepositoryContainer.init(); 22 | const postsRepository = postsRepositoryContainer.init(db.schemas); 23 | const usersRepository = usersRepositoryContainer.init(db.schemas); 24 | const recourceLimiterRepository = recourceLimiterRepositoryContainer.init(); 25 | const authService = authServiceContainer.init({ 26 | authenticationRepository, 27 | usersRepository, 28 | recourceLimiterRepository, 29 | }); 30 | const postsService = postsServiceContainer.init({ 31 | postsRepository, 32 | }); 33 | const usersService = usersServiceContainer.init({ 34 | usersRepository, 35 | postsRepository, 36 | }); 37 | const app = appContainer.init({ 38 | authService, 39 | postsService, 40 | usersService, 41 | }); 42 | websocketsContainer.init(app); 43 | 44 | let server; 45 | 46 | ((isClusterRequired) => { 47 | // if it is a master process then call setting up worker process 48 | if (isClusterRequired && cluster.isMaster) { 49 | setupWorkerProcesses(); 50 | } else { 51 | // to setup server configurations and share port address for incoming requests 52 | server = app.listen(httpPort, () => { 53 | logging.info(`Listening on *:${httpPort}`); 54 | }); 55 | } 56 | })(true); 57 | 58 | const shutdown = signals.init(async () => { 59 | await db.close(); 60 | await server.close(); 61 | }); 62 | 63 | (async () => { 64 | try { 65 | await db.connect(); 66 | } catch (error) { 67 | await shutdown(); 68 | } 69 | })(); 70 | 71 | process.on('SIGINT', shutdown); 72 | process.on('SIGTERM', shutdown); 73 | -------------------------------------------------------------------------------- /src/data/repositories/posts/index.js: -------------------------------------------------------------------------------- 1 | // DATA LAYER 2 | // postRepository: 3 | // is used to provide an abstraction on top of the database ( and possible other data sources) 4 | // so other parts of the application are decoupled from the specific database implementation. 5 | // Furthermore it can hide the origin of the data from it's consumers. 6 | // It is possible to fetch the entities from different sources like inmemory cache, 7 | // network or the db without the need to alter the consumers code. 8 | // I am using a factory function (using object literal and prototype) to pass methods on prototype chain 9 | // With factory functions(closures) we can have data privacy. 10 | 11 | const errors = require('../../../common/errors'); 12 | const mapper = require('../../mapper'); 13 | const PostDomainModel = require('../../../domain/posts/model'); 14 | 15 | const DEFAULT_PAGINATION_CONTENT = { 16 | pagination: {}, 17 | data: [], 18 | }; 19 | 20 | const handleUsersPaginationResponse = (response) => { 21 | if (!response.docs || response.docs.length <= 0) { 22 | return DEFAULT_PAGINATION_CONTENT; 23 | } 24 | const postsList = { 25 | data: response.docs.map((doc) => mapper.toDomainModel(doc, PostDomainModel)), 26 | pagination: { 27 | total: response.total, 28 | limit: response.limit, 29 | page: response.page, 30 | pages: response.pages, 31 | }, 32 | }; 33 | return postsList; 34 | }; 35 | 36 | const getPaginationOptions = (options) => ({ 37 | lean: true, 38 | page: options.page || 1, 39 | limit: options.limit || 25, 40 | sort: { created: -1 }, 41 | }); 42 | 43 | const getQueryObject = (options) => { 44 | const queries = { 45 | userId: options.userId, 46 | }; 47 | if (options.publisher) { 48 | queries.publisher = { 49 | $regex: new RegExp(options.publisher), 50 | $options: 'i', 51 | }; 52 | } 53 | return queries; 54 | }; 55 | 56 | const postStore = { 57 | async listUserPosts(options) { 58 | const { Post: postSchema } = this.getSchemas(); 59 | const docs = await postSchema.paginate(getQueryObject(options), getPaginationOptions(options)); 60 | return handleUsersPaginationResponse(docs); 61 | }, 62 | async createUserPost(options) { 63 | const { Post: postSchema } = this.getSchemas(); 64 | const newPost = new postSchema({ 65 | userId: options.userId, 66 | imageUrl: options.imageUrl, 67 | description: options.description, 68 | publisher: options.publisher, 69 | }); 70 | const doc = await newPost.save(); 71 | return mapper.toDomainModel(doc, PostDomainModel); 72 | }, 73 | async getUserPost(options) { 74 | const { Post: postSchema } = this.getSchemas(); 75 | const doc = await postSchema.findOne({ userId: options.userId, _id: options.postId }).lean().exec(); 76 | if (!doc) { 77 | throw new errors.NotFound(`Post with id ${options.postId} not found.`); 78 | } 79 | return mapper.toDomainModel(doc, PostDomainModel); 80 | }, 81 | }; 82 | 83 | module.exports.init = ({ Post }) => Object.assign(Object.create(postStore), { 84 | getSchemas() { 85 | return { 86 | Post, 87 | }; 88 | }, 89 | }); 90 | -------------------------------------------------------------------------------- /src/domain/auth/service.js: -------------------------------------------------------------------------------- 1 | // DOMAIN LAYER 2 | // Has the userRepository as a dependency. The authService does not know 3 | // nor does it care where the user models came from. This is abstracted away 4 | // by the implementation of the repositories. It just calls the needed repositories 5 | // gets the results and usually applies some business logic on them. 6 | const errors = require('../../common/errors'); 7 | const { 8 | getRetryAfterSeconds, 9 | } = require('../../common/utils/helper'); 10 | 11 | function init({ 12 | authenticationRepository, 13 | usersRepository, 14 | recourceLimiterRepository, 15 | }) { 16 | const getUsernameKey = (username) => `${username}`; 17 | 18 | async function register(options) { 19 | return usersRepository.createUser(options); 20 | } 21 | 22 | async function handleIncorrectLoginPassword({ 23 | usernameKey, 24 | user, 25 | }) { 26 | try { 27 | const promises = []; 28 | if (user) { 29 | promises.push(recourceLimiterRepository.consumeUserPointsForFailedLogin(usernameKey)); 30 | } 31 | await Promise.all(promises); 32 | throw new errors.Unauthorized('WRONG_PASSWORD'); 33 | } catch (rlRejected) { 34 | if (rlRejected instanceof Error) { 35 | throw rlRejected; 36 | } else { 37 | const retryAfterSecs = getRetryAfterSeconds(rlRejected.msBeforeNext); 38 | throw new errors.TooManyRequests(`Too Many Requests. Retry after ${String(retryAfterSecs)} seconds`); 39 | } 40 | } 41 | } 42 | 43 | async function handleCorrectLoginPassword({ 44 | resUsername, 45 | usernameKey, 46 | user, 47 | }) { 48 | if (resUsername !== null && resUsername.consumedPoints > 0) { 49 | await recourceLimiterRepository.deleteUserKeyForFailedLogin(usernameKey); 50 | } 51 | const token = await authenticationRepository.createUserToken(user); 52 | return { 53 | token, 54 | user, 55 | }; 56 | } 57 | 58 | async function login({ 59 | email, 60 | password, 61 | }) { 62 | const usernameKey = getUsernameKey(email); 63 | const [ 64 | resUsername, 65 | ] = await Promise.all([ 66 | recourceLimiterRepository.getUserKeyForFailedLogin(usernameKey), 67 | ]); 68 | let retrySecs = 0; 69 | if (resUsername !== null && resUsername.consumedPoints > recourceLimiterRepository.maxConsecutiveFailsByUsername) { 70 | retrySecs = getRetryAfterSeconds(resUsername.msBeforeNext); 71 | } 72 | if (retrySecs > 0) { 73 | throw new errors.TooManyRequests(`Too Many Requests. Retry after ${String(retrySecs)} seconds`); 74 | } else { 75 | const user = await usersRepository.getUser({ 76 | email, 77 | password, 78 | }); 79 | const isPasswordCorrect = await authenticationRepository.comparePassword(password, user.password) 80 | .catch((err) => { 81 | console.error(`Error in authentication of user with email: ${email}`, err); 82 | return undefined; 83 | }); 84 | if (!isPasswordCorrect) { 85 | return handleIncorrectLoginPassword({ 86 | email, 87 | usernameKey, 88 | user, 89 | }); 90 | } 91 | return handleCorrectLoginPassword({ 92 | resUsername, 93 | usernameKey, 94 | user, 95 | }); 96 | } 97 | } 98 | 99 | return { 100 | register, 101 | login, 102 | }; 103 | } 104 | 105 | module.exports.init = init; 106 | -------------------------------------------------------------------------------- /src/swagger/paths/auth/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | register: { 3 | post: { 4 | tags: [ 5 | 'Auth', 6 | ], 7 | security: [ 8 | { 9 | Bearer: [], 10 | }, 11 | ], 12 | description: 'Add a new user in DB.', 13 | parameters: [ 14 | { 15 | name: 'body', 16 | in: 'body', 17 | description: 'Body for creating new user', 18 | schema: { 19 | type: 'object', 20 | required: [ 21 | 'name', 22 | 'surname', 23 | 'username', 24 | 'email', 25 | 'password', 26 | ], 27 | properties: { 28 | name: { 29 | type: 'string', 30 | }, 31 | surname: { 32 | type: 'string', 33 | }, 34 | username: { 35 | type: 'string', 36 | }, 37 | email: { 38 | type: 'string', 39 | }, 40 | password: { 41 | type: 'string', 42 | }, 43 | }, 44 | }, 45 | }, 46 | ], 47 | produces: [ 48 | 'application/json', 49 | ], 50 | responses: { 51 | 200: { 52 | description: 'New user registered', 53 | schema: { 54 | type: 'object', 55 | properties: { 56 | data: { 57 | type: 'object', 58 | $ref: '#/definitions/User', 59 | }, 60 | }, 61 | }, 62 | }, 63 | 400: { 64 | $ref: '#/components/responses/400', 65 | }, 66 | 401: { 67 | $ref: '#/components/responses/401', 68 | }, 69 | 404: { 70 | $ref: '#/components/responses/404', 71 | }, 72 | 500: { 73 | $ref: '#/components/responses/500', 74 | }, 75 | }, 76 | }, 77 | }, 78 | login: { 79 | post: { 80 | tags: [ 81 | 'Auth', 82 | ], 83 | security: [ 84 | { 85 | Bearer: [], 86 | }, 87 | ], 88 | description: 'Login user in dashboard.', 89 | parameters: [ 90 | { 91 | name: 'body', 92 | in: 'body', 93 | description: 'Body for login', 94 | schema: { 95 | type: 'object', 96 | required: [ 97 | 'email', 98 | 'password', 99 | ], 100 | properties: { 101 | email: { 102 | type: 'string', 103 | }, 104 | password: { 105 | type: 'string', 106 | }, 107 | }, 108 | }, 109 | }, 110 | ], 111 | produces: [ 112 | 'application/json', 113 | ], 114 | responses: { 115 | 200: { 116 | description: 'User logins', 117 | schema: { 118 | type: 'object', 119 | properties: { 120 | 121 | data: { 122 | type: 'object', 123 | properties: { 124 | user: { 125 | type: 'object', 126 | $ref: '#/definitions/User', 127 | }, 128 | token: { 129 | type: 'object', 130 | $ref: '#/definitions/Token', 131 | }, 132 | 133 | }, 134 | }, 135 | }, 136 | }, 137 | }, 138 | 400: { 139 | $ref: '#/components/responses/400', 140 | }, 141 | 401: { 142 | $ref: '#/components/responses/401', 143 | }, 144 | 404: { 145 | $ref: '#/components/responses/404', 146 | }, 147 | 500: { 148 | $ref: '#/components/responses/500', 149 | }, 150 | }, 151 | }, 152 | }, 153 | }; 154 | -------------------------------------------------------------------------------- /tests/presentation/http/routes/posts.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | require('dotenv').config(); 3 | const request = require('supertest'); 4 | const { expect } = require('chai'); 5 | const sinon = require('sinon'); 6 | const jwt = require('jsonwebtoken'); 7 | const Post = require('../../../../src/domain/posts/model'); 8 | const postsRepositoryContainer = require('../../../../src/data/repositories/posts'); 9 | const usersRepositoryContainer = require('../../../../src/data/repositories/users'); 10 | const authenticationRepositoryContainer = require('../../../../src/data/repositories/authenticationRepository'); 11 | const recourceLimiterRepositoryContainer = require('../../../../src/data/repositories/recourceLimiterRepository'); 12 | const authServiceContainer = require('../../../../src/domain/auth/service'); 13 | const postsServiceContainer = require('../../../../src/domain/posts/service'); 14 | const usersServiceContainer = require('../../../../src/domain/users/service'); 15 | const appContainer = require('../../../../src/presentation/http/app'); 16 | 17 | const db = sinon.stub(); 18 | const authenticationRepository = authenticationRepositoryContainer.init(); 19 | const postsRepository = postsRepositoryContainer.init(db); 20 | const usersRepository = usersRepositoryContainer.init(db); 21 | const recourceLimiterRepository = recourceLimiterRepositoryContainer.init(); 22 | const authService = authServiceContainer.init({ 23 | authenticationRepository, 24 | usersRepository, 25 | recourceLimiterRepository, 26 | }); 27 | const postsService = postsServiceContainer.init({ 28 | postsRepository, 29 | }); 30 | const usersService = usersServiceContainer.init({ 31 | usersRepository, 32 | postsRepository, 33 | }); 34 | const app = appContainer.init({ 35 | authService, 36 | postsService, 37 | usersService, 38 | }); 39 | 40 | const postData = [ 41 | new Post({ 42 | imageUrl: 'www.test.com', 43 | description: 'test text content1', 44 | publisher: 'Alex', 45 | created: '2017-08-30T08:17:50.460Z', 46 | _id: '5a3b9a95e9f13308a30740a5', 47 | }), 48 | new Post({ 49 | imageUrl: 'www.test1.com', 50 | description: 'test text content2', 51 | publisher: 'Aris', 52 | created: '2017-08-30T08:17:50.460Z', 53 | _id: 'testId2', 54 | }), 55 | new Post({ 56 | imageUrl: 'www.test2.com', 57 | description: 'test text content3', 58 | publisher: 'Pantelis', 59 | created: '2017-08-30T08:17:50.460Z', 60 | _id: 'testId3', 61 | }), 62 | ]; 63 | 64 | const jwtSecret = process.env.JWT_SECRET; 65 | const testEmail = 'kent@gmail.com'; 66 | const testFullname = 'klark kent'; 67 | const testID = '5fb02910c74ce3697859cee2'; 68 | const wrongUserId = '3ca12910c74ce3697859caa1'; 69 | let testToken; 70 | 71 | describe('post routes test', () => { 72 | describe('GET /users/:userId/post test', () => { 73 | beforeEach((done) => { 74 | sinon.stub(postsService, 'listUserPosts'); 75 | testToken = jwt.sign({ email: testEmail, fullName: testFullname, _id: testID }, jwtSecret, { expiresIn: 120 }); 76 | return done(); 77 | }); 78 | afterEach(() => { 79 | postsService.listUserPosts.restore(); 80 | }); 81 | it('should return 200 an array of posts', async () => { 82 | postsService.listUserPosts.resolves(postData); 83 | const res = await request(app) 84 | .get(`/users/${testID}/posts`) 85 | .set('Authorization', `Bearer ${testToken}`); 86 | expect(res.statusCode).to.to.eql(200); 87 | expect(res.body.length).to.to.eql(postData.length); 88 | }); 89 | it('should return 403 when token of another user is used', async () => { 90 | postsService.listUserPosts.resolves(postData); 91 | const res = await request(app) 92 | .get(`/users/${wrongUserId}/posts`) 93 | .set('Authorization', `Bearer ${testToken}`); 94 | expect(res.statusCode).to.to.eql(403); 95 | }); 96 | it('should return 401 when no token send', () => request(app) 97 | .get(`/users/${testID}/posts`) 98 | .expect(401)); 99 | it('should return 401 when we send invalid token', () => request(app) 100 | .get(`/users/${testID}/posts`) 101 | .set('Authorization', `Bearer ${testToken}test`) 102 | .expect(401)); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/swagger/paths/posts/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'list-posts': { 3 | parameters: [ 4 | { 5 | name: 'publisher', 6 | in: 'query', 7 | required: false, 8 | description: 'Optional: publisher to get his posts docs', 9 | type: 'string', 10 | }, 11 | { 12 | name: 'limit', 13 | in: 'query', 14 | required: false, 15 | description: 'Limit for pagination', 16 | type: 'integer', 17 | }, 18 | { 19 | name: 'page', 20 | in: 'query', 21 | required: false, 22 | description: 'Page for pagination', 23 | type: 'integer', 24 | }, 25 | ], 26 | get: { 27 | tags: [ 28 | 'Posts', 29 | ], 30 | security: [ 31 | { 32 | Bearer: [], 33 | }, 34 | ], 35 | summary: 'Get list of posts based on query params otherwise gets all posts docs that user added', 36 | responses: { 37 | 200: { 38 | description: 'List of posts found', 39 | schema: { 40 | type: 'object', 41 | properties: { 42 | data: { 43 | type: 'array', 44 | items: { 45 | $ref: '#/definitions/Post', 46 | }, 47 | }, 48 | pagination: { 49 | type: 'object', 50 | $ref: '#/definitions/Pagination', 51 | }, 52 | }, 53 | }, 54 | }, 55 | 400: { 56 | $ref: '#/components/responses/400', 57 | }, 58 | 401: { 59 | $ref: '#/components/responses/401', 60 | }, 61 | 404: { 62 | $ref: '#/components/responses/404', 63 | }, 64 | 500: { 65 | $ref: '#/components/responses/500', 66 | }, 67 | }, 68 | }, 69 | }, 70 | 'post-posts': { 71 | parameters: [ 72 | { 73 | name: 'body', 74 | in: 'body', 75 | description: 'Body for creating new post', 76 | schema: { 77 | type: 'object', 78 | required: [ 79 | 'imageUrl', 80 | 'publisher', 81 | ], 82 | properties: { 83 | imageUrl: { 84 | type: 'string', 85 | }, 86 | publisher: { 87 | type: 'string', 88 | }, 89 | description: { 90 | type: 'string', 91 | }, 92 | }, 93 | }, 94 | }, 95 | ], 96 | post: { 97 | tags: [ 98 | 'Post', 99 | ], 100 | security: [ 101 | { 102 | Bearer: [], 103 | }, 104 | ], 105 | summary: 'Create new post mannualy', 106 | responses: { 107 | 200: { 108 | description: 'Post created', 109 | schema: { 110 | type: 'object', 111 | properties: { 112 | data: { 113 | type: 'object', 114 | $ref: '#/definitions/Post', 115 | }, 116 | }, 117 | }, 118 | }, 119 | 400: { 120 | $ref: '#/components/responses/400', 121 | }, 122 | 401: { 123 | $ref: '#/components/responses/401', 124 | }, 125 | 404: { 126 | $ref: '#/components/responses/404', 127 | }, 128 | 500: { 129 | $ref: '#/components/responses/500', 130 | }, 131 | }, 132 | }, 133 | }, 134 | 'get-posts': { 135 | parameters: [ 136 | { 137 | name: 'postId', 138 | in: 'path', 139 | required: true, 140 | description: 'Id of post doc', 141 | type: 'string', 142 | }, 143 | ], 144 | get: { 145 | tags: [ 146 | 'Posts', 147 | ], 148 | security: [ 149 | { 150 | Bearer: [], 151 | }, 152 | ], 153 | summary: 'Get specific post based on postId', 154 | responses: { 155 | 200: { 156 | description: 'Post found', 157 | schema: { 158 | type: 'object', 159 | properties: { 160 | data: { 161 | type: 'object', 162 | $ref: '#/definitions/Post', 163 | }, 164 | }, 165 | }, 166 | }, 167 | 400: { 168 | $ref: '#/components/responses/400', 169 | }, 170 | 401: { 171 | $ref: '#/components/responses/401', 172 | }, 173 | 404: { 174 | $ref: '#/components/responses/404', 175 | }, 176 | 500: { 177 | $ref: '#/components/responses/500', 178 | }, 179 | }, 180 | }, 181 | }, 182 | }; 183 | -------------------------------------------------------------------------------- /src/presentation/http/middleware/endpointValidator.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | const passwordComplexity = require('joi-password-complexity'); 3 | const { 4 | body, 5 | param, 6 | validationResult, 7 | } = require('express-validator'); 8 | const { errorHandler } = require('@dimosbotsaris/express-error-handler'); 9 | const errors = require('../../../common/errors'); 10 | const { 11 | PASSWORD_COMPLEXITY, 12 | } = require('../../../common/constants'); 13 | 14 | const isMongoObjectID = (value) => /^[0-9a-fA-F]{24}$/.test(value); 15 | 16 | const requireSameUser = () => [ 17 | param('userId') 18 | .exists() 19 | .withMessage('You can manage a user doc for your own user;') 20 | .custom((value, { req }) => { 21 | if (value !== req.user._id) { 22 | return false; 23 | } 24 | return true; 25 | }) 26 | .withMessage({ 27 | message: 'You can manage a user doc for your own user;', 28 | status: 403, 29 | }), 30 | ]; 31 | 32 | const requireValidUserBody = () => { 33 | let passwordErrorMsg; 34 | return [ 35 | body('email') 36 | .exists() 37 | .isEmail() 38 | .withMessage({ 39 | message: 'email not provided. Make sure you have a "email" property in your body params.', 40 | status: 400, 41 | }), 42 | body('name') 43 | .exists() 44 | .withMessage({ 45 | message: 'name not provided. Make sure you have a "name" property in your body params.', 46 | status: 400, 47 | }), 48 | body('username') 49 | .exists() 50 | .withMessage({ 51 | message: 'username not provided. Make sure you have a "username" property in your body params.', 52 | status: 400, 53 | }), 54 | body('surname') 55 | .exists() 56 | .withMessage({ 57 | message: 'surname not provided. Make sure you have a "surname" property in your body params.', 58 | status: 400, 59 | }), 60 | body('password') 61 | .exists() 62 | .withMessage({ 63 | message: 'password not provided. Make sure you have a "password" property in your body params.', 64 | status: 400, 65 | }) 66 | .custom((value) => { 67 | const passwordChecking = passwordComplexity(PASSWORD_COMPLEXITY, 'Password').validate(value); 68 | passwordErrorMsg = passwordChecking && passwordChecking.error && passwordChecking.error.details && Array.isArray(passwordChecking.error.details) 69 | ? passwordChecking.error.details[0].message 70 | : null; 71 | if (passwordErrorMsg) { 72 | return false; 73 | } 74 | return true; 75 | }) 76 | .withMessage(() => ({ 77 | message: passwordErrorMsg, 78 | status: 400, 79 | })), 80 | ]; 81 | }; 82 | 83 | const requireBodyParamsForLogin = () => [ 84 | body('email') 85 | .exists() 86 | .isEmail() 87 | .withMessage({ 88 | message: 'email not provided. Make sure you have a "email" property in your body params.', 89 | status: 400, 90 | }), 91 | body('password') 92 | .exists() 93 | .withMessage({ 94 | message: 'password not provided. Make sure you have a "password" property in your body params.', 95 | status: 400, 96 | }), 97 | ]; 98 | 99 | const requireValidPostId = () => [ 100 | param('postId') 101 | .exists() 102 | .withMessage({ 103 | message: 'Add a valid post id.', 104 | status: 400, 105 | }) 106 | .custom((value) => { 107 | if (!isMongoObjectID(value)) { 108 | return false; 109 | } 110 | return true; 111 | }) 112 | .withMessage({ 113 | message: 'Add a valid post id.', 114 | status: 400, 115 | }), 116 | ]; 117 | 118 | const requireValidPostBody = () => [ 119 | body('imageUrl') 120 | .exists() 121 | .isURL() 122 | .withMessage({ 123 | message: 'imageUrl not provided. Make sure you have a "imageUrl" property in your body params.', 124 | status: 400, 125 | }), 126 | body('publisher') 127 | .exists() 128 | .withMessage({ 129 | message: 'publisher not provided. Make sure you have a "publisher" property in your body params.', 130 | status: 400, 131 | }), 132 | ]; 133 | 134 | const validate = (req, res, next) => { 135 | const validationErrors = validationResult(req); 136 | if (validationErrors.isEmpty()) { 137 | return next(); 138 | } 139 | const validationError = validationErrors.array({ 140 | onlyFirstError: true, 141 | })[0]; 142 | const errMsg = validationError?.msg?.message || 'Bad request'; 143 | const errStatus = validationError?.msg?.status || 400; 144 | return errorHandler({ trace: true })(new errors[errStatus](errMsg, 'BAD_BODY_PARAMS'), req, res, next); 145 | }; 146 | 147 | const validateCreateUserBody = () => [ 148 | requireValidUserBody(), 149 | validate, 150 | ]; 151 | 152 | const validateUserToken = () => [ 153 | requireSameUser(), 154 | validate, 155 | ]; 156 | 157 | const validateLoginBodyParams = () => [ 158 | requireBodyParamsForLogin(), 159 | validate, 160 | ]; 161 | 162 | const validatePostId = () => [ 163 | requireValidPostId(), 164 | validate, 165 | ]; 166 | 167 | const validateCreatePostBody = () => [ 168 | requireValidPostBody(), 169 | validate, 170 | ]; 171 | 172 | module.exports = { 173 | validateUserToken, 174 | validateCreateUserBody, 175 | validateLoginBodyParams, 176 | validatePostId, 177 | validateCreatePostBody, 178 | }; 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://api.travis-ci.org/eldimious/nodejs-api-showcase.svg?branch=master)](https://api.travis-ci.org/eldimious/nodejs-api-showcase) 2 | 3 | # Typescript # 4 | You can take a look at Typescript source code at the branch [typescript](https://github.com/eldimious/nodejs-api-showcase/tree/typescript) 5 | 6 | # What is this repository for? # 7 | Node.js app architecture showcase using [Express](https://www.npmjs.com/package/express), [MongoDB](https://www.mongodb.com/) and [Mongoose](http://mongoosejs.com/) as ORM. The project has an implementation of an authentication system that uses JSON Web Token to manage users' login data in Node.js web server. You can start your Node.js projects building on this boilerplate. 8 | 9 | # Architecture Overview # 10 | The app is designed to use a layered architecture. The architecture is heavily influenced by the Clean Architecture.[Clean Architecture](https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html) is an architecture where: 11 | 12 | 1. **does not depend on the existence of some framework, database, external agency.** 13 | 2. **does not depend on UI** 14 | 3. **the business rules can be tested without the UI, database, web server, or any external element.** 15 | 16 |

17 | 18 | 19 |

20 | 21 |

22 | 23 | 24 |

25 | 26 | Also, in entry point(server.js), I use Dependency Injection(DI). There are many reasons using Dependency Injection as: 27 | 1. Decoupling 28 | 2. Easier unit testing 29 | 3. Faster development 30 | 4. Dependency injection is really helpful when it comes to testing. You can easily mock your modules' dependencies using this pattern. 31 | 32 | You can take a look at this tutorial: `https://blog.risingstack.com/dependency-injection-in-node-js/`. 33 | According to DI: 34 | A. High-level modules should not depend on low-level modules. Both should depend on abstractions. 35 | B. Abstractions should not depend on details. 36 | 37 | The code style being used is based on the airbnb js style guide. 38 | 39 | 40 | ## Data Layer ## 41 | 42 | The data layer is implemented using repositories, that hide the underlying data sources (database, network, cache, etc), and provides an abstraction over them so other parts of the application that make use of the repositories, don't care about the origin of the data and are decoupled from the specific implementations used, like the Mongoose ORM that is used by this app. Furthermore, the repositories are responsible to map the entities they fetch from the data sources to the models used in the applications. This is important to enable the decoupling. 43 | 44 | ## Domain Layer ## 45 | 46 | The domain layer is implemented using services. They depend on the repositories to get the app models and apply the business rules on them. They are not coupled to a specific database implementation and can be reused if we add more data sources to the app or even if we change the database for example from MongoDB to Couchbase Server. 47 | 48 | ## Routes/Controller Layer ## 49 | 50 | This layer is being used in the express app and depends on the domain layer (services). Here we define the routes that can be called from outside. The services are always used as the last middleware on the routes and we must not rely on res.locals from express to get data from previous middlewares. That means that the middlewares registered before should not alter data being passed to the domain layer. They are only allowed to act upon the data without modification, like for example validating the data and skipping calling next(). 51 | 52 | ## Entry point ## 53 | 54 | The entry point for the applications is the server.js file. It does not depend on express.js or other node.js frameworks. It is responsible for instantiating the application layers, connecting to the db and mounting the http server to the specified port. 55 | 56 | # Quick start # 57 | 58 | ### Prerequisites ### 59 | 60 | Create an .env file in project root to register the following required environment variables: 61 | - `DATABASE_URL` - MongoDB connection URL 62 | - `HTTP_PORT` - port of server 63 | - `JWT_SECRET` - we will use secret to generate our JSON web tokens 64 | - `REDIS_URL` - redis client 65 | 66 | ### Use Docker: ### 67 | 68 | You can use Docker to start the app locally. The Dockerfile and the docker-compose.yml are already provided for you. You have to run the following command: 69 | 70 | ```shell 71 | docker-compose up 72 | ``` 73 | 74 | ### Use the npm scripts: ### 75 | 76 | ```shell 77 | npm run test 78 | ``` 79 | for running tests. 80 | 81 | 82 | # Endpoints # 83 | 84 | You can take a look at API's endpoints navigated to http://localhost:5555/docs/ 85 | 86 | ## Auth Routes ## 87 | 88 | ### Register ### 89 | 90 | ```shell 91 | POST /auth/register 92 | ``` 93 | 94 | Body Params: 95 | ```shell 96 | { 97 | name, 98 | surname, 99 | username, 100 | email, 101 | password 102 | } 103 | ``` 104 | 105 | Description: creates a new user. Password is stored in bcrypt format. 106 | 107 | 108 | ### Login ### 109 | 110 | ```shell 111 | POST /auth/login 112 | ``` 113 | 114 | Body Params: 115 | ```shell 116 | { 117 | email, 118 | password 119 | } 120 | ``` 121 | 122 | **Description**: logs in to the server. Server will return a JWT token and user's info as: 123 | 124 | ```js 125 | { 126 | "status": "success", 127 | "data": { 128 | "token": { 129 | id: "eyJhbGciOiJIUzxxxxxxx.eyJlbWFpbCI6ImRpbW9zdGhlbxxxxxxxxxxxxx.axxxxxxxxxx", 130 | expiresIn: 86400, 131 | }, 132 | "user": { 133 | "id": "mongoID", 134 | "fullName": "clark kent", 135 | "username": "superman", 136 | "email": "clarkkent@test.com", 137 | "created": "2018-01-08T14:43:32.480Z" 138 | } 139 | } 140 | } 141 | ``` 142 | 143 | ## User Routes ## 144 | 145 | In order to be able to retrieve posts list, user should send a Bearer token using Authorization header, otherwise server will answer with 401. 146 | 147 | ### Get specific user ### 148 | 149 | ```shell 150 | GET /users/:userId 151 | ``` 152 | 153 | **Description**: Gets specific user. 154 | 155 | ## Posts Routes ## 156 | 157 | In order to be able to retrieve posts list, user should send a Bearer token using Authorization header, otherwise server will answer with 401. 158 | 159 | ### Posts List ### 160 | 161 | ```shell 162 | GET /users/:userId/posts 163 | ``` 164 | 165 | Query Params: 166 | ```shell 167 | { 168 | publisher, {String} (optional) 169 | } 170 | ``` 171 | **Description**: retrieves user's posts docs, based on his token and his id. 172 | 173 | 174 | ```shell 175 | POST /users/:userId/posts 176 | ``` 177 | 178 | Body Params: 179 | ```shell 180 | { 181 | imageUrl, {String} 182 | publisher, {String} 183 | description, {String} (optional) 184 | } 185 | ``` 186 | **Description**: creates a new post doc in DB for user. 187 | 188 | ```shell 189 | GET /users/:userId/posts/:postId 190 | ``` 191 | 192 | **Description**: Gets specific user's post. 193 | 194 | # Packages and Tools # 195 | 196 | - [Node.js](https://nodejs.org/en/) 197 | - [Express](https://www.npmjs.com/package/express) 198 | - [Mongoose](http://mongoosejs.com/) 199 | - [Mongoose-Pagination](https://github.com/edwardhotchkiss/mongoose-paginate) 200 | - [Express-jsend](https://www.npmjs.com/package/express-jsend) 201 | - [Express-validator](https://github.com/ctavan/express-validator) 202 | - [Bcrypt](https://github.com/dcodeIO/bcrypt.js) 203 | - [Jsonwebtoken](https://github.com/auth0/node-jsonwebtoken) 204 | - [Redis](https://github.com/luin/ioredis) 205 | - [Express-winston](https://github.com/bithavoc/express-winston) 206 | - [Winston](https://github.com/winstonjs/winston) 207 | - [Mocha](https://www.npmjs.com/package/mocha) 208 | - [Chai](https://www.npmjs.com/package/chai) 209 | - [Sinon](https://www.npmjs.com/package/sinon) 210 | - [Supertest](https://github.com/visionmedia/supertest) 211 | - [Eslint](https://www.npmjs.com/package/eslint) 212 | 213 | 214 | ## Support Me 215 | 216 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Y8Y797KCA) 217 | 218 | ## Show your support 219 | 220 | Give a ⭐️ if this project helped you! 221 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------