diff --git a/.eslintrc b/.eslintrc index 6a6f8e8..08a119a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -4,6 +4,9 @@ "airbnb", "plugin:jsx-a11y/recommended" ], + "globals": { + "Kit": true + }, "env": { "node": true, "browser": true, diff --git a/README.md b/README.md index f5a8355..9759555 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ This starter kit is designed to help you start project as soon as possible. It contain all necessary things to develop and maintain a project. +## Before start + +You need to read these docs: + +1. [Error handling](/docs/error.md) + +Remove all code which we use for showing template opportunities. We left indicator for every place which need to remove `// REMOVE_PROD` + ## Getting started In this project we used [ESLint](https://eslint.org/docs/user-guide/integrations#editors) and [Stylelint](https://stylelint.io/user-guide/complementary-tools/#editor-plugins), so at the beginning check your code editor plugins or settings to support these linters. diff --git a/client/components/common/Navigation.jsx b/client/components/common/Navigation.jsx index 6019119..0dc6e5a 100644 --- a/client/components/common/Navigation.jsx +++ b/client/components/common/Navigation.jsx @@ -15,6 +15,9 @@ const Navigation = () => (
  • Form components
  • +
  • + Example page +
  • diff --git a/client/pages/Example.jsx b/client/pages/Example.jsx new file mode 100644 index 0000000..d96e9d6 --- /dev/null +++ b/client/pages/Example.jsx @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; + +import { Button } from 'Form'; + +class ExampleComponents extends Component { + state = { + error: '', + } + + showError = () => { + fetch('/api/test/error') + .then(response => response.json()) + .then(data => this.setState({ error: JSON.stringify(data) })); + } + + render() { + const { error } = this.state; + + return ( +
    + +

    + {`Error: ${error}`} +

    +
    + ); + } +} + +export default ExampleComponents; diff --git a/client/routing/Routes.jsx b/client/routing/Routes.jsx index 06e34ba..85a6dfc 100644 --- a/client/routing/Routes.jsx +++ b/client/routing/Routes.jsx @@ -5,6 +5,7 @@ import MainLayout from 'Layout/Main'; import Index from 'Pages/Index'; import FormComponents from 'Pages/FormComponents'; +import ExampleComponents from 'Pages/Example'; import NoMatch from 'Pages/NoMatch'; const Routes = () => ( @@ -13,6 +14,7 @@ const Routes = () => ( + diff --git a/docs/error.md b/docs/error.md new file mode 100644 index 0000000..f1ccc55 --- /dev/null +++ b/docs/error.md @@ -0,0 +1,127 @@ +# Error handling + +This is a document how we most work with **Error** on current project. + +For correct works with **Error** in project please read [this article](https://expressjs.com/en/guide/error-handling.html) and use `Kit.CustomError` as `Error` object + +Example of handling: + +```js +router.get('/error', (req, res, next) => { + try { + // ...some code + } catch(e) { + next(new Kit.CustomError('UNAUTHORIZED_ACCESS', 401)); + } +}); +``` + +## Error structure + +This structure you must send to client when error is happened and you can get this structure from `Kit.CustomError` by `.get()` method + +```js +{ + "errors": [ + { + "parameter": "start_time", + "details": "invalid date", + "code": "INVALID_PARAMETER", + "value": "", + "message": "Expected time, got \"\" for start_time", + "userMessage": "Expected time, got \"\" for start_time" + } + ], + "request": { + "params": { + "account_id": "hkk5" + } + }, + "metadata": {} +} +``` + +## Error codes & what they mean + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    HTTP CodeError Code
    403ACCOUNT_NOT_FOUND
    403ACTION_NOT_ALLOWED
    400EXCLUSIVE_PARAMETERS
    400FEATURE_NOT_AVAILABLE
    400ILLEGAL_CHARACTERS
    500INTERNAL_ERROR
    400INVALID_PARAMETER
    400INVALID_USER
    400INVALID_USER_ID
    400MISSING_PARAMETERS
    404NOT_FOUND
    400REQUEST_TOO_COMPLEX
    404ROUTE_NOT_FOUND
    503SERVICE_UNAVAILABLE
    503OVER_CAPACITY
    429TOO_MANY_REQUESTS
    401UNAUTHORIZED_ACCESS
    403USER_NOT_FOUND
    diff --git a/server/config/error.js b/server/config/error.js new file mode 100644 index 0000000..0f40a3e --- /dev/null +++ b/server/config/error.js @@ -0,0 +1,97 @@ +module.exports = { + ACCOUNT_NOT_FOUND: { + message: 'Account not found', + userMessage: 'Account not found', + code: 403, + }, + ACTION_NOT_ALLOWED: { + message: 'Action not allowed', + userMessage: 'Action not allowed', + code: 403, + }, + EXCLUSIVE_PARAMETERS: { + message: 'Exclusive parameters', + userMessage: 'Exclusive parameters', + code: 400, + }, + FEATURE_NOT_AVAILABLE: { + message: 'Feature not available', + userMessage: 'Feature not available', + code: 400, + }, + ILLEGAL_CHARACTERS: { + message: 'Illegal characters', + userMessage: 'Illegal characters', + code: 400, + }, + INTERNAL_ERROR: { + message: 'Server error', + userMessage: 'Server error', + code: 500, + }, + INVALID_PARAMETER: { + message: 'Invalid parameter', + userMessage: 'Invalid parameter', + code: 400, + }, + INVALID_USER: { + message: 'Invalid user', + userMessage: 'Invalid user', + code: 400, + }, + INVALID_USER_ID: { + message: 'Invalid user ID', + userMessage: 'Invalid user ID', + code: 400, + }, + MISSING_PARAMETERS: { + message: 'Missing parameters', + userMessage: 'Missing parameters', + code: 400, + }, + NOT_FOUND: { + message: 'Not Found', + userMessage: 'Not Found', + code: 404, + }, + REQUEST_TOO_COMPLEX: { + message: 'Request too complex', + userMessage: 'Request too complex', + code: 400, + }, + ROUTE_NOT_FOUND: { + message: 'Route not found', + userMessage: 'Route not found', + code: 404, + }, + SERVICE_UNAVAILABLE: { + message: 'Service unavailable', + userMessage: 'Service unavailable', + code: 503, + }, + OVER_CAPACITY: { + message: 'Over capacity', + userMessage: 'Over capacity', + code: 503, + }, + TOO_MANY_REQUESTS: { + message: 'Too many request', + userMessage: 'Too many request', + code: 429, + }, + UNAUTHORIZED_ACCESS: { + message: 'Unathorized access', + userMessage: 'Unathorized access', + code: 401, + }, + USER_NOT_FOUND: { + message: 'User not found', + userMessage: 'User not found', + code: 403, + }, + FORBIDDEN: { + message: 'Forbidden', + userMessage: 'Forbidden', + code: 403, + }, +}; diff --git a/server/index.js b/server/index.js index b977ab4..c63a7c6 100644 --- a/server/index.js +++ b/server/index.js @@ -9,9 +9,14 @@ const methodOverride = require('method-override'); const swaggerUIDist = require('swagger-ui-dist'); const routes = require('./routes/index.route'); +const CustomError = require('./utils/error'); +const errorHandler = require('./middlewares/error'); require('dotenv-safe').config(); +global.Kit = {}; +Kit.CustomError = CustomError; + const app = express(); app.db = require('./models'); @@ -35,6 +40,8 @@ if (!isProduction) { app.get('/*', (req, res) => res.sendFile(path.join(__dirname, `../${isProduction ? 'dist' : 'client'}/index.html`))); +app.use(errorHandler); + const port = process.env.APP_PORT || 3001; const host = process.env.APP_HOST || 'localhost'; diff --git a/server/middlewares/error.js b/server/middlewares/error.js new file mode 100644 index 0000000..0ec1f0b --- /dev/null +++ b/server/middlewares/error.js @@ -0,0 +1,19 @@ +function errorHandling(err, req, res, next) { + if (res.headersSent) { + return next(err); + } + + res.status(err.code); + return res.json(err.get()); +} + +// process.on('unhandledRejection', (err) => { +// console.error(err); +// }); + +// process.on('uncaughtException', (err) => { +// console.error(err); +// process.exit(1); +// }); + +module.exports = errorHandling; diff --git a/server/routes/index.route.js b/server/routes/index.route.js index ff85702..3456095 100644 --- a/server/routes/index.route.js +++ b/server/routes/index.route.js @@ -2,6 +2,8 @@ const express = require('express'); const authRoutes = require('./auth.route'); const userRoutes = require('./user.route'); +// REMOVE_PROD: in real app you need remove this variable +const testRoutes = require('./test.route'); const router = express.Router(); @@ -11,4 +13,8 @@ router.use('/auth', authRoutes); // mount user routes at /user router.use('/user', userRoutes); +// REMOVE_PROD: in real app you need remove this route +router.use('/test', testRoutes); + + module.exports = router; diff --git a/server/routes/test.route.js b/server/routes/test.route.js new file mode 100644 index 0000000..f07b80f --- /dev/null +++ b/server/routes/test.route.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/error', (req, res, next) => { + next(new Kit.CustomError('UNAUTHORIZED_ACCESS', 401)); +}); + +module.exports = router; diff --git a/server/tests/error.test.js b/server/tests/error.test.js new file mode 100644 index 0000000..a42ee49 --- /dev/null +++ b/server/tests/error.test.js @@ -0,0 +1,35 @@ +// REMOVE_PROD: remove this file + +const _ = require('lodash'); +const base = require('./base.test'); +const Errors = require('../config/error'); + +const { request, truncate } = base; +const apiBase = process.env.API_BASE || '/api'; + +describe('GET /test/error', () => { + before(() => truncate()); + after(() => truncate()); + + it('error structure', () => request + .get(`${apiBase}/test/error`) + .expect('Content-Type', /json/) + .expect(401) + .expect(({ body }) => { + if (!(Array.isArray(body.errors) && body.errors.length && body.request && body.metadata)) { + throw new Error(` + Error response must have next structure: + { + "errors": [{ ... }], + "request": { ... }, + "metadata": {} + } + `); + } + const error = _.get(body, 'errors[0]', {}); + + if (error.userMessage !== Errors.UNAUTHORIZED_ACCESS.userMessage && error.code !== 401) { + throw new Error('Error has incorrect data'); + } + })); +}); diff --git a/server/utils/error.js b/server/utils/error.js new file mode 100644 index 0000000..1c99736 --- /dev/null +++ b/server/utils/error.js @@ -0,0 +1,68 @@ +const _ = require('lodash'); +const ErrorHash = require('../config/error'); + +const isProduction = process.env.NODE_ENV === 'production'; + +const defaultError = { + parameter: '', + details: '', + code: 'ERROR', + value: '', + message: 'Something went wrong.', + userMessage: 'Something went wrong.', +}; + +class CustomError extends Error { + static createError(errorCode) { + const error = ErrorHash[errorCode]; + + if (!error) { + return { + ...defaultError, + code: errorCode, + }; + } + + return { + ...defaultError, + ...error, + }; + } + + constructor(errorCode, httpCode, ...params) { + super(params); + this.errors = [CustomError.createError(errorCode)]; + this.code = httpCode || this.errors[0].code; + this.message = this.errors[0].message; + this.name = 'Server error'; + this.request = {}; + this.metadata = {}; + } + + get() { + const errors = this.errors.slice().map((error) => { + if (isProduction) { + // eslint-disable-next-line + delete error.message; + } + + return error; + }); + + return { + errors, + request: this.request, + metadata: this.metadata, + }; + } + + upsertMetadata(data) { + if (!_.isObject(data)) { + throw new Error("Metadata must be 'object'"); + } + + this.metadata = this.metadata ? { ...this.metadata, ...data } : data; + } +} + +module.exports = CustomError;