diff --git a/.eslintrc.json b/.eslintrc.json index 508213f..9c1fc19 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -13,6 +13,8 @@ }, "rules": { "no-unused-vars": ["error", { "vars": "all", "args": "after-used", "ignoreRestSiblings": false }], - "semi": "off" + "semi": "off", + "space-before-function-paren": "off", + "comma-dangle":"off" } } diff --git a/.gitignore b/.gitignore index 7fe89e3..415c268 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ node_modules yarn-error.log .DS_Store -.vscode coverage/ mockedFolder lib/ diff --git a/.husky/pre-commit b/.husky/pre-commit index 0b71a80..ae01268 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,4 +2,4 @@ . "$(dirname -- "$0")/_/husky.sh" -pnpm run prettify && pnpm run lint +pnpm run prettier:check && pnpm run lint diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..88a9464 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "stylelint.enable": false +} \ No newline at end of file diff --git a/README.md b/README.md index 77452bc..52784fb 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ # http-server + + + + This package is meant to be used as a quick way to setup an Express application. The module does not have too much defined. The default functionality is the following. - `cors` enabled diff --git a/__tests__/app.spec.mjs b/__tests__/app.spec.mjs index bae96e0..27a1c30 100644 --- a/__tests__/app.spec.mjs +++ b/__tests__/app.spec.mjs @@ -1,80 +1,48 @@ -import request from 'supertest' -import { jest } from '@jest/globals' -import { expressApp } from '../src/app.mjs' +import request from 'supertest'; +import { jest } from '@jest/globals'; +import { expressApp } from '../src/app.mjs'; -const mockedMiddleware = jest.fn +const mockedMiddleware = jest.fn; describe('App', () => { it('should be defined', () => { - expect(expressApp).toBeInstanceOf(Function) - }) - - it('should throw Error if no routes are specified', () => { - expect(() => expressApp({ middleware: [] })).toThrowError('Either routes is not defined or it is not an Array') - }) - - it('should throw Error if routes is not an Array', () => { - expect(() => expressApp({ routes: 100, middleware: [] })).toThrowError('Either routes is not defined or it is not an Array') - }) - - it('should throw Error if no middleware are specified', () => { - expect(() => expressApp({ routes: [] })).toThrowError('Either middleware is not defined or it is not an Array') - }) - - it('should throw Error if middleware is not an Array', () => { - expect(() => expressApp({ routes: [], middleware: 100 })).toThrowError('Either middleware is not defined or it is not an Array') - }) - - it('should throw Error if middleware is not an array of functions', () => { - expect(() => expressApp({ routes: [], middleware: [100] })).toThrowError('handler must be a function. Actual type is "number"') - }) - - it('should throw Error if routes do not provide a valid HTTP method', () => { - expect(() => expressApp({ middleware: [mockedMiddleware], routes: [{ method: 'posted' }] })).toThrowError('posted is not a valid HTTP method \n Allowed methods are get - delete - post - put - patch') - }) - - it('should throw Error if routes do not provide a function for the handler', () => { - expect(() => expressApp({ middleware: [mockedMiddleware], routes: [{ method: 'get', handler: true }] })).toThrowError('handler must be a function. Actual type is "boolean"') - }) - - it('should throw Error if routes do not provide a string for the path', () => { - expect(() => expressApp({ middleware: [mockedMiddleware], routes: [{ method: 'get', handler: () => {}, path: true }] })).toThrowError('path must be a string. Actual type is "boolean"') - }) - - it('should throw Error if routes provide an empty string for the path', () => { - expect(() => expressApp({ middleware: [mockedMiddleware], routes: [{ method: 'get', handler: () => {}, path: '' }] })).toThrowError('path can not be an empty string') - }) - - it('should throw Error if routes provide a string for the path that does not start with "/"', () => { - expect(() => expressApp({ middleware: [mockedMiddleware], routes: [{ method: 'get', handler: () => {}, path: 'somePath' }] })).toThrowError('path has to start with "/"') - }) + expect(expressApp).toBeInstanceOf(Function); + }); it('should have a /health route by default', async () => { - const app = expressApp({ middleware: [mockedMiddleware], routes: [] }) - const resp = await request(app).get('/health') + const app = expressApp({ middleware: [mockedMiddleware], routes: [] }); + const resp = await request(app).get('/health'); - expect(resp.ok).toEqual(true) - expect(resp.type).toEqual('text/html') - expect(resp.text).toEqual('ok') - }) + expect(resp.ok).toEqual(true); + expect(resp.type).toEqual('text/html'); + expect(resp.text).toEqual('ok'); + }); it('should handle 404 requests', async () => { - const app = expressApp({ middleware: [mockedMiddleware], routes: [] }) - const resp = await request(app).get('/notFound') + const app = expressApp({ middleware: [mockedMiddleware], routes: [] }); + const resp = await request(app).get('/notFound'); - expect(resp.status).toEqual(404) - expect(resp.type).toEqual('text/html') - expect(resp.text).toEqual('No handler found for /notFound') - }) + expect(resp.status).toEqual(404); + expect(resp.type).toEqual('text/html'); + expect(resp.text).toEqual('No handler found for /notFound'); + }); it('should register all routes passed', async () => { - const routes = [{ method: 'get', handler: (req, res) => { res.json({ value: 100 }) }, path: '/someRoute' }] - const server = expressApp({ routes, middleware: [mockedMiddleware] }) - - const resp = await request(server).get('/someRoute').send() - - expect(resp.ok).toEqual(true) - expect(resp.type).toEqual('application/json') - expect(resp.body).toEqual({ value: 100 }) - }) -}) + const routes = [ + { + method: 'get', + handler: (req, res) => { + res.json({ value: 100 }); + }, + path: '/someRoute', + }, + ]; + const server = expressApp({ routes, middleware: [mockedMiddleware] }); + + const resp = await request(server).get('/someRoute').send(); + + expect(resp.ok).toEqual(true); + expect(resp.type).toEqual('application/json'); + expect(resp.body).toEqual({ value: 100 }); + }); +}); diff --git a/__tests__/server.spec.mjs b/__tests__/server.spec.mjs index 861f8c5..f854df8 100644 --- a/__tests__/server.spec.mjs +++ b/__tests__/server.spec.mjs @@ -1,32 +1,110 @@ -import { httpServer } from '../src/server.mjs' +import { jest } from '@jest/globals'; +import { httpServer } from '../src/server.mjs'; +import { + ValidatorChain, + PortValidator, + OptionsValidator, + RoutesValidator, + MiddlewareValidator, +} from '../src/validator/index.mjs'; +import utils from '../src/utils/index.mjs'; -let server +let server; beforeAll(() => { - server = httpServer({ routes: [], middleware: [() => {}] }) -}) + server = httpServer({ routes: [], middleware: [() => {}] }); +}); -afterAll(() => { - server.close() -}) +afterAll(done => { + server.close(done); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); describe('Server', () => { it('should be defined', () => { - expect(httpServer).toBeInstanceOf(Function) - }) + expect(httpServer).toBeInstanceOf(Function); + }); it('should return an instance of Express Server', () => { - expect(server.constructor.name).toEqual('Server') - }) + expect(server.constructor.name).toEqual('Server'); + }); it('should default port to 3000 if is not specified', () => { - expect(server.address().port).toEqual(3000) - }) + expect(server.address().port).toEqual(3000); + }); it('should set the port number', () => { - const server = httpServer({ port: 6969, routes: [], middleware: [() => {}] }) + const server = httpServer({ + port: 6969, + routes: [], + middleware: [() => {}], + }); + + expect(server.address().port).toEqual(6969); + server.close(); + }); + + it('should validate the options by calling all the Validators', () => { + jest.spyOn(ValidatorChain.prototype, 'startValidation'); + jest.spyOn(PortValidator.prototype, 'validate'); + jest.spyOn(OptionsValidator.prototype, 'validate'); + jest.spyOn(RoutesValidator.prototype, 'validate'); + jest.spyOn(MiddlewareValidator.prototype, 'validate'); + + const server = httpServer({ + port: 6969, + routes: [], + middleware: [() => {}], + }); + + expect(ValidatorChain.prototype.startValidation).toBeCalledTimes(1); + expect(PortValidator.prototype.validate).toBeCalledTimes(1); + expect(OptionsValidator.prototype.validate).toBeCalledTimes(1); + expect(RoutesValidator.prototype.validate).toBeCalledTimes(1); + expect(MiddlewareValidator.prototype.validate).toBeCalledTimes(1); + + server.close(); + }); + + it('should stop the app if the Validator fails', () => { + // process.exit it's been spied on jest/setupTests.js + jest.spyOn(console, 'error'); + + const server = httpServer({ + port: '6969', + routes: [], + middleware: [() => {}], + }); + + expect(console.error).toBeCalledTimes(1); + expect(console.error).toBeCalledWith( + '"port" property should be typeof number but string was given' + ); + expect(process.exit).toBeCalledTimes(1); + expect(process.exit).toBeCalledWith(1); + + server.close(); + }); + + it('should call msgBuilder if the Validator is successful', () => { + jest.spyOn(utils, 'msgBuilder'); + + const appParams = { + port: 6969, + routes: [], + middleware: [() => {}], + }; + const server = httpServer(appParams); + + expect(utils.msgBuilder).toBeCalledTimes(1); + expect(utils.msgBuilder).toBeCalledWith({ + ...appParams, + options: { useCors: true }, + }); - expect(server.address().port).toEqual(6969) - server.close() - }) -}) + server.close(); + }); +}); diff --git a/__tests__/utils.spec.mjs b/__tests__/utils.spec.mjs new file mode 100644 index 0000000..960b861 --- /dev/null +++ b/__tests__/utils.spec.mjs @@ -0,0 +1,20 @@ +import utils from '../src/utils/index.mjs'; + +describe('msgBuilder', () => { + it('should be defined', () => { + expect(utils.msgBuilder).toBeInstanceOf(Function); + }); + + it('should return a msg for the props passed', () => { + const props = { + port: 4000, + routes: [{ handler: () => {}, path: '/some', method: 'get' }], + middleware: [() => {}], + options: { useCors: true }, + }; + + expect(utils.msgBuilder(props)).toEqual( + 'Your app is running with the following settings:\nPORT: 4000\nROUTES: [{"path":"/some","method":"get"}]\nMIDDLEWARE: 1 registered\nOPTIONS: {"useCors":true}' + ); + }); +}); diff --git a/__tests__/validator.spec.mjs b/__tests__/validator.spec.mjs new file mode 100644 index 0000000..542aa3d --- /dev/null +++ b/__tests__/validator.spec.mjs @@ -0,0 +1,414 @@ +import { jest } from '@jest/globals'; +import { + Validator, + PortValidator, + ValidatorChain, + OptionsValidator, + RoutesValidator, + MiddlewareValidator, + utils, +} from '../src/validator/index.mjs'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('utils', () => { + describe('validatorErrorMsgGenerator', () => { + it('should be an instance of function', () => { + expect(utils.validatorErrorMsgGenerator).toBeInstanceOf(Function); + }); + it.each([ + [ + 'port', + 'undefined', + '"port" property should be typeof number but undefined was given', + ], + [ + 'options', + 'undefined', + '"options" property should be typeof object but undefined was given', + ], + [ + 'options.useCors', + 'undefined', + '"options.useCors" property should be typeof boolean but undefined was given', + ], + [ + 'handler', + 'undefined', + '"handler" property should be typeof function but undefined was given', + ], + ])( + 'utils.validatorErrorMsgGenerator(%s, %s)', + (propToValidate, typeofProp, errorMsg) => { + const expected = { + errorMsg, + isValid: false, + }; + expect( + utils.validatorErrorMsgGenerator(propToValidate, typeofProp) + ).toMatchObject(expected); + } + ); + }); +}); + +describe('Validator', () => { + it('should be defined', () => { + expect(Validator).toBeInstanceOf(Function); + }); + + it('should have a "validate" property and be a Function', () => { + expect(Validator.prototype).toHaveProperty('validate'); + expect(Validator.prototype.validate).toBeInstanceOf(Function); + }); + + it('should have a "setNextValidator" property and be a Function', () => { + expect(Validator.prototype).toHaveProperty('setNextValidator'); + expect(Validator.prototype.setNextValidator).toBeInstanceOf(Function); + }); + + it('should have a "nextValidator" property initialized to null', () => { + const validator = new Validator(); + expect(validator).toHaveProperty('nextValidator'); + expect(validator.nextValidator).toEqual(null); + }); +}); + +describe('ValidatorChain', () => { + it('should be defined', () => { + expect(ValidatorChain).toBeInstanceOf(Function); + }); + + it('should have a first, last and validators properties', () => { + const chain = new ValidatorChain(); + expect(chain.first).toEqual(null); + expect(chain.last).toEqual(null); + expect(chain.validators).toEqual([]); + }); + + it('should have a "startValidation" property', () => { + expect(ValidatorChain.prototype).toHaveProperty('startValidation'); + }); + + it('should be able to chain 1 validator', () => { + jest.spyOn(ValidatorChain.prototype, 'add'); + jest.spyOn(PortValidator.prototype, 'setNextValidator'); + + const portValidator = new PortValidator(); + const chain = new ValidatorChain().add(portValidator); + + expect(ValidatorChain.prototype.add).toBeCalledTimes(1); + expect(ValidatorChain.prototype.add).toBeCalledWith(portValidator); + expect(PortValidator.prototype.setNextValidator).not.toBeCalled(); + expect(chain.validators).toEqual(['PortValidator']); + expect(chain.first).toEqual(portValidator); + expect(chain.last).toEqual(portValidator); + }); + + it('should be able to chain 2 validators', () => { + jest.spyOn(ValidatorChain.prototype, 'add'); + jest.spyOn(PortValidator.prototype, 'setNextValidator'); + + const firstValidator = new PortValidator(); + const secondValidator = new PortValidator(); + const chain = new ValidatorChain().add(firstValidator).add(secondValidator); + + expect(ValidatorChain.prototype.add).toBeCalledTimes(2); + expect(ValidatorChain.prototype.add).nthCalledWith(1, firstValidator); + expect(ValidatorChain.prototype.add).nthCalledWith(2, secondValidator); + expect(chain.validators).toEqual(['PortValidator', 'PortValidator']); + expect(chain.first).toEqual(firstValidator); + expect(chain.last).toEqual(secondValidator); + }); + + it('should start a validation', () => { + jest.spyOn(ValidatorChain.prototype, 'add'); + jest.spyOn(ValidatorChain.prototype, 'startValidation'); + jest.spyOn(PortValidator.prototype, 'validate'); + + const options = { + port: 9000, + }; + const portValidator = new PortValidator(); + const validation = new ValidatorChain() + .add(portValidator) + .startValidation() + .validate(options); + + expect(ValidatorChain.prototype.add).toBeCalledTimes(1); + expect(ValidatorChain.prototype.add).toBeCalledWith(portValidator); + expect(ValidatorChain.prototype.startValidation).toBeCalledTimes(1); + expect(PortValidator.prototype.validate).toBeCalledTimes(1); + expect(PortValidator.prototype.validate).toBeCalledWith(options); + expect(validation).toEqual({ isValid: true }); + }); +}); + +describe('PortValidator', () => { + it('should extend Validator base class', () => { + expect(PortValidator.prototype).toBeInstanceOf(Validator); + }); + + it('should validate the port when is wrong', () => { + jest.spyOn(Validator.prototype, 'validate'); + + const example = new PortValidator().validate({ port: '9000' }); + + expect(Validator.prototype.validate).toBeCalledTimes(0); + expect(example).toEqual({ + errorMsg: '"port" property should be typeof number but string was given', + isValid: false, + }); + }); + + it('should call Validator.validate if port is valid', () => { + jest.spyOn(Validator.prototype, 'validate'); + + const example = new PortValidator().validate({ port: 9000 }); + + expect(Validator.prototype.validate).toBeCalledTimes(1); + expect(example).toEqual({ + isValid: true, + }); + }); +}); + +describe('OptionsValidator', () => { + it('should extend Validator base class', () => { + expect(OptionsValidator.prototype).toBeInstanceOf(Validator); + }); + + it('should validate the options when are wrong if they are not an object', () => { + jest.spyOn(Validator.prototype, 'validate'); + + const example = new OptionsValidator().validate({ options: '9000' }); + + expect(Validator.prototype.validate).toBeCalledTimes(0); + expect(example).toEqual({ + errorMsg: + '"options" property should be typeof object but string was given', + isValid: false, + }); + }); + + it('should validate the wrong options passed', () => { + jest.spyOn(Validator.prototype, 'validate'); + + const example = new OptionsValidator().validate({ + options: { useCors: 'test' }, + }); + + expect(Validator.prototype.validate).toBeCalledTimes(0); + expect(example).toEqual({ + errorMsg: + '"options.useCors" property should be typeof boolean but string was given', + isValid: false, + }); + }); + + it('should call Validator.validate if the options are valid', () => { + jest.spyOn(Validator.prototype, 'validate'); + + const example = new OptionsValidator().validate({ + options: { useCors: true }, + }); + + expect(Validator.prototype.validate).toBeCalledTimes(1); + expect(example).toEqual({ + isValid: true, + }); + }); +}); + +describe('RoutesValidator', () => { + it('should extend Validator base class', () => { + expect(RoutesValidator.prototype).toBeInstanceOf(Validator); + }); + + it('should validate that "routes" is an array', () => { + jest.spyOn(Validator.prototype, 'validate'); + + const example = new RoutesValidator().validate({ routes: '9000' }); + + expect(Validator.prototype.validate).toBeCalledTimes(0); + expect(example).toEqual({ + errorMsg: '"routes" property should be an Array but string was given', + isValid: false, + }); + }); + + it('should check that all props in a route are valid', () => { + jest.spyOn(Validator.prototype, 'validate'); + jest.spyOn(RoutesValidator.prototype, 'validateRoutesProps'); + + const example = new RoutesValidator().validate({ + routes: [ + { + methods: 'foo', + pat: '/route', + }, + ], + }); + + expect(Validator.prototype.validate).toBeCalledTimes(0); + expect(RoutesValidator.prototype.validateRoutesProps).toBeCalledTimes(1); + expect(RoutesValidator.prototype.validateRoutesProps).toBeCalledWith([ + 'methods', + 'pat', + ]); + expect(example).toEqual({ + errorMsg: + '"routes" item have invalid or missing props.\nReceived: methods-pat\nValid: method-path-handler', + isValid: false, + }); + }); + + it('should check that in a route the "method" is valid', () => { + jest.spyOn(Validator.prototype, 'validate'); + jest.spyOn(RoutesValidator.prototype, 'validateRouteMethod'); + + const example = new RoutesValidator().validate({ + routes: [ + { + method: true, + handler: '', + path: '', + }, + ], + }); + + expect(Validator.prototype.validate).toBeCalledTimes(0); + expect(RoutesValidator.prototype.validateRouteMethod).toBeCalledTimes(1); + expect(example).toEqual({ + errorMsg: 'true is not a valid method in a route', + isValid: false, + }); + }); + + it('should check that in a route the "path" is valid', () => { + jest.spyOn(Validator.prototype, 'validate'); + jest.spyOn(RoutesValidator.prototype, 'validateRoutePath'); + + const example = new RoutesValidator().validate({ + routes: [ + { + method: 'get', + handler: '', + path: '', + }, + ], + }); + + expect(Validator.prototype.validate).toBeCalledTimes(0); + expect(RoutesValidator.prototype.validateRoutePath).toBeCalledTimes(1); + expect(example).toEqual({ + errorMsg: '"path" in a route must start with "/"', + isValid: false, + }); + }); + + it('should check that in a route the "handler" is valid', () => { + jest.spyOn(Validator.prototype, 'validate'); + jest.spyOn(RoutesValidator.prototype, 'validateRoutePath'); + + const example = new RoutesValidator().validate({ + routes: [ + { + method: 'get', + handler: '', + path: '/test', + }, + ], + }); + + expect(Validator.prototype.validate).toBeCalledTimes(0); + expect(RoutesValidator.prototype.validateRoutePath).toBeCalledTimes(1); + expect(example).toEqual({ + errorMsg: + '"handler" property should be typeof function but string was given', + isValid: false, + }); + }); + + it('should call Validator.validate if routes are valid', () => { + jest.spyOn(Validator.prototype, 'validate'); + jest.spyOn(RoutesValidator.prototype, 'validateRoutePath'); + + const example = new RoutesValidator().validate({ + routes: [ + { + method: 'get', + handler: () => {}, + path: '/test', + }, + ], + }); + + expect(Validator.prototype.validate).toBeCalledTimes(1); + expect(RoutesValidator.prototype.validateRoutePath).toBeCalledTimes(1); + expect(example).toEqual({ + isValid: true, + }); + }); +}); + +describe('MiddlewareValidator', () => { + it('should extend Validator base class', () => { + expect(MiddlewareValidator.prototype).toBeInstanceOf(Validator); + }); + + it('should validate that "middleware" is an array', () => { + jest.spyOn(Validator.prototype, 'validate'); + + const example = new MiddlewareValidator().validate({ middleware: '9000' }); + + expect(Validator.prototype.validate).toBeCalledTimes(0); + expect(example).toEqual({ + errorMsg: '"middleware" property should be an Array but string was given', + isValid: false, + }); + }); + + it('should validate that "middleware" is an array of functions', () => { + jest.spyOn(Validator.prototype, 'validate'); + + const example = new MiddlewareValidator().validate({ middleware: [true] }); + + expect(Validator.prototype.validate).toBeCalledTimes(0); + expect(example).toEqual({ + errorMsg: + '"middleware" property should be typeof function but boolean was given', + isValid: false, + }); + }); + + it('should validate that a "middleware" has 3 or 4 arguments', () => { + jest.spyOn(Validator.prototype, 'validate'); + + const example = new MiddlewareValidator().validate({ + middleware: [(req, res) => {}], + }); + + expect(Validator.prototype.validate).toBeCalledTimes(0); + expect(example).toEqual({ + errorMsg: + '"middleware" should have 3 args (req, res, next) or 4 args for error (error, req, res, next)', + isValid: false, + }); + }); + + it('should call Validator.validate if middleware are valid', () => { + jest.spyOn(Validator.prototype, 'validate'); + + const example = new MiddlewareValidator().validate({ + middleware: [(req, res, next) => {}], + }); + + expect(Validator.prototype.validate).toBeCalledTimes(1); + expect(example).toEqual({ + isValid: true, + }); + }); +}); diff --git a/jest/setupTests.js b/jest/setupTests.js new file mode 100644 index 0000000..a65da9d --- /dev/null +++ b/jest/setupTests.js @@ -0,0 +1,4 @@ +import { jest } from '@jest/globals'; + +console.error = jest.fn(); +jest.spyOn(process, 'exit').mockImplementation(error => error); diff --git a/package.json b/package.json index c187d2f..588c4e6 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "type": "module", "main": "./lib/server.mjs", "scripts": { - "start": "node server.mjs", - "dev": "nodemon server.mjs", + "start": "node ./src/server.mjs", + "dev": "nodemon ./src/server.mjs", "test:dev": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch --detectOpenHandles", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --collect-coverage --detectOpenHandles --forceExit", "test:clear:cache": "jest --clearCache", @@ -16,6 +16,7 @@ "release:notes": "./scripts/release_notes.sh", "release": "./scripts/release.sh", "prepare": "husky install", + "prettier:check": "prettier --check ./src/*", "prettify": "prettier ./src/* --write" }, "keywords": [ @@ -54,6 +55,9 @@ "jest": { "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(mjs?|js?)$", "transform": {}, + "setupFilesAfterEnv": [ + "/jest/setupTests.js" + ], "moduleFileExtensions": [ "mjs", "js" diff --git a/src/app.mjs b/src/app.mjs index 3147e8f..d250e18 100644 --- a/src/app.mjs +++ b/src/app.mjs @@ -5,28 +5,12 @@ import bodyParser from 'body-parser'; import { healthRouter } from './routes/health.mjs'; /** - * @preserve - * @param {{ - * routes: Array<{ - * method: 'get' | 'put' | 'delete' | 'post' | 'patch', - * path: string, - * handler: function(Request, Response) - * }> - * middleware: Array - * }} - * @returns @type Express + * @param {import("../utils/types.mjs").Params} + * + * @returns {import("express").Express} */ -function expressApp({ routes, middleware } = {}) { - if (!routes || !Array.isArray(routes)) { - throw new Error('Either routes is not defined or it is not an Array'); - } - - if (!middleware || !Array.isArray(middleware)) { - throw new Error('Either middleware is not defined or it is not an Array'); - } - +function expressApp({ routes, middleware }) { const app = express(); - const validHTTPMethods = ['get', 'delete', 'post', 'put', 'patch']; app.use(cors()); app.use(morgan('dev')); @@ -35,27 +19,6 @@ function expressApp({ routes, middleware } = {}) { app.use(bodyParser.json()); for (const { path, handler, method } of routes) { - if (!validHTTPMethods.includes(method)) { - const errorMsg = `${method} is not a valid HTTP method \n Allowed methods are ${validHTTPMethods.join( - ' - ' - )}`; - throw new Error(errorMsg); - } - - if (typeof handler !== 'function') { - throw new Error( - `handler must be a function. Actual type is "${typeof handler}"` - ); - } - - if (typeof path !== 'string') { - throw new Error(`path must be a string. Actual type is "${typeof path}"`); - } else if (path.length === 0) { - throw new Error('path can not be an empty string'); - } else if (!path.startsWith('/')) { - throw new Error('path has to start with "/"'); - } - // Register all the handlers app[method](path, handler); } @@ -65,12 +28,6 @@ function expressApp({ routes, middleware } = {}) { }); for (const handler of middleware) { - if (typeof handler !== 'function') { - throw new Error( - `handler must be a function. Actual type is "${typeof handler}"` - ); - } - // Register all the middleware app.use(handler); } diff --git a/src/server.mjs b/src/server.mjs index dd30e76..6d38641 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -1,26 +1,50 @@ import { expressApp } from './app.mjs'; +import utils from './utils/index.mjs'; +import { + ValidatorChain, + PortValidator, + OptionsValidator, + RoutesValidator, + MiddlewareValidator, +} from './validator/index.mjs'; /** * @preserve - * @param {{ - * port: number, - * routes: Array<{ - * method: 'get' | 'put' | 'delete' | 'post' | 'patch', - * path: string, - * handler: function(Request, Response) - * }> - * middleware: Array - * }} + * @param {import("../utils/types.mjs").Params} * - * @returns @type Express + * @returns {import("http").Server} */ - -function httpServer({ port, routes, middleware = [] } = {}) { +function httpServer({ + port, + routes = [], + middleware = [], + options = { useCors: true }, +} = {}) { const PORT = port || 3000; - const server = expressApp({ routes, middleware }); + const validation = new ValidatorChain() + .add(new PortValidator()) + .add(new OptionsValidator()) + .add(new RoutesValidator()) + .add(new MiddlewareValidator()) + .startValidation() + .validate({ port: PORT, routes, middleware, options }); + + if (!validation.isValid) { + console.error(validation.errorMsg); + + process.exit(1); + } + + const server = expressApp({ routes, middleware, options }); + const successMsg = utils.msgBuilder({ + port: PORT, + routes, + middleware, + options, + }); return server.listen(PORT, () => { - console.log(`App running on: ${PORT}`); + console.log(`\x1b[32m${successMsg}\x1b[0m`); }); } diff --git a/src/utils/index.mjs b/src/utils/index.mjs new file mode 100644 index 0000000..6a111b6 --- /dev/null +++ b/src/utils/index.mjs @@ -0,0 +1,24 @@ +/** + * + * @param {import("../../utils/types.mjs").Params} params + */ +function msgBuilder(params) { + const entries = Object.entries(params); + let msg = 'Your app is running with the following settings:'; + + for (const [key, value] of entries) { + let stringifiedValue = JSON.stringify(value); + + if (key === 'middleware' && Array.isArray(value)) { + stringifiedValue = `${value.length} registered`; + } + + msg += `\n${key.toUpperCase()}: ${stringifiedValue}`; + } + + return msg; +} + +export default { + msgBuilder, +}; diff --git a/src/validator/baseClass.mjs b/src/validator/baseClass.mjs new file mode 100644 index 0000000..ef59fe4 --- /dev/null +++ b/src/validator/baseClass.mjs @@ -0,0 +1,28 @@ +export class Validator { + /** + * @type {import("../../utils/types.mjs").ValidatorClass} + */ + nextValidator = null; + + /** + * @param {import("../../utils/types.mjs").Params} model + * @returns {import("../../utils/types.mjs").Validator} + */ + validate(model) { + if (this.nextValidator) { + return this.nextValidator.validate(model); + } + + return { + isValid: true, + }; + } + + /** + * + * @param {import("../../utils/types.mjs").ValidatorClass} nextValidator + */ + setNextValidator(nextValidator) { + this.nextValidator = nextValidator; + } +} diff --git a/src/validator/index.mjs b/src/validator/index.mjs new file mode 100644 index 0000000..ff92577 --- /dev/null +++ b/src/validator/index.mjs @@ -0,0 +1,7 @@ +export { Validator } from './baseClass.mjs'; +export { PortValidator } from './portValidator.mjs'; +export { OptionsValidator } from './optionsValidator.mjs'; +export { MiddlewareValidator } from './middlewareValidator.mjs'; +export { RoutesValidator } from './routesValidator.mjs'; +export { ValidatorChain } from './validatorChain.mjs'; +export * as utils from './utils.mjs'; diff --git a/src/validator/middlewareValidator.mjs b/src/validator/middlewareValidator.mjs new file mode 100644 index 0000000..858a4b9 --- /dev/null +++ b/src/validator/middlewareValidator.mjs @@ -0,0 +1,37 @@ +import { Validator, utils } from './index.mjs'; + +export class MiddlewareValidator extends Validator { + /** + * @param {import("../../utils/types.mjs").Params} model + * @returns {import("../../utils/types.mjs").Validator} + */ + validate(model) { + const { middleware } = model; + const typeofMiddleware = typeof middleware; + + if (!Array.isArray(middleware)) { + return { + isValid: false, + errorMsg: `"middleware" property should be an Array but ${typeofMiddleware} was given`, + }; + } + + for (const fn of middleware) { + const typeofFn = typeof fn; + + if (typeofFn !== 'function') { + return utils.validatorErrorMsgGenerator('middleware', typeofFn); + } + + if (fn.length !== 3 && fn.length !== 4) { + return { + errorMsg: + '"middleware" should have 3 args (req, res, next) or 4 args for error (error, req, res, next)', + isValid: false, + }; + } + } + + return super.validate(model); + } +} diff --git a/src/validator/optionsValidator.mjs b/src/validator/optionsValidator.mjs new file mode 100644 index 0000000..bceb21c --- /dev/null +++ b/src/validator/optionsValidator.mjs @@ -0,0 +1,25 @@ +import { Validator, utils } from './index.mjs'; + +export class OptionsValidator extends Validator { + /** + * @param {import("../../utils/types.mjs").Params} model + * @returns {import("../../utils/types.mjs").Validator} + */ + validate(model) { + const typeofOptions = typeof model?.options; + const typeofOptionsUseCors = typeof model?.options?.useCors; + + if (typeofOptions !== 'object') { + return utils.validatorErrorMsgGenerator('options', typeofOptions); + } + + if (typeofOptionsUseCors !== 'boolean') { + return utils.validatorErrorMsgGenerator( + 'options.useCors', + typeofOptionsUseCors + ); + } + + return super.validate(model); + } +} diff --git a/src/validator/portValidator.mjs b/src/validator/portValidator.mjs new file mode 100644 index 0000000..515140a --- /dev/null +++ b/src/validator/portValidator.mjs @@ -0,0 +1,17 @@ +import { Validator, utils } from './index.mjs'; + +export class PortValidator extends Validator { + /** + * @param {import("../../utils/types.mjs").Params} model + * @returns {import("../../utils/types.mjs").Validator} + */ + validate(model) { + const typeofPort = typeof model.port; + + if (typeofPort !== 'number') { + return utils.validatorErrorMsgGenerator('port', typeofPort); + } + + return super.validate(model); + } +} diff --git a/src/validator/routesValidator.mjs b/src/validator/routesValidator.mjs new file mode 100644 index 0000000..d896929 --- /dev/null +++ b/src/validator/routesValidator.mjs @@ -0,0 +1,86 @@ +import { Validator, utils } from './index.mjs'; + +export class RoutesValidator extends Validator { + static routeProps = ['method', 'path', 'handler']; + /** + * @param {string[]} routeProps + * @returns {boolean} + */ + validateRoutesProps(routeProps) { + return RoutesValidator.routeProps + .map(prop => routeProps.includes(prop)) + .every(Boolean); + } + + /** + * @param {import("../../utils/types.mjs").Operations} operation + * @returns {boolean} + */ + validateRouteMethod(operation) { + return ['delete', 'get', 'patch', 'post', 'put'].includes(operation); + } + + /** + * + * @param {string} path + * @returns + */ + validateRoutePath(path) { + return path.length > 0 && `${path}`.startsWith('/'); + } + + /** + * @param {import("../../utils/types.mjs").Params} model + * @returns {import("../../utils/types.mjs").Validator} + */ + validate(model) { + const { routes } = model; + const typeofRoutes = typeof routes; + + if (!Array.isArray(routes)) { + return { + isValid: false, + errorMsg: `"routes" property should be an Array but ${typeofRoutes} was given`, + }; + } + + /** + * @type {import("../../utils/types.mjs").Operations[]} + */ + + for (const route of routes) { + const routeProps = Object.keys(route); + const hasInvalidProps = !this.validateRoutesProps(routeProps); + + if (hasInvalidProps) { + return { + isValid: false, + errorMsg: `"routes" item have invalid or missing props.\nReceived: ${routeProps.join( + '-' + )}\nValid: ${RoutesValidator.routeProps.join('-')}`, + }; + } + + if (!this.validateRouteMethod(route.method)) { + return { + isValid: false, + errorMsg: `${route.method} is not a valid method in a route`, + }; + } + + if (!this.validateRoutePath(route.path)) { + return { + isValid: false, + errorMsg: '"path" in a route must start with "/"', + }; + } + + const typeofHandler = typeof route.handler; + if (typeofHandler !== 'function') { + return utils.validatorErrorMsgGenerator('handler', typeofHandler); + } + } + + return super.validate(model); + } +} diff --git a/src/validator/utils.mjs b/src/validator/utils.mjs new file mode 100644 index 0000000..b5dc47c --- /dev/null +++ b/src/validator/utils.mjs @@ -0,0 +1,20 @@ +/** + * + * @param {"port" | "options" | "options.useCors" | "handler" | "middleware"} propToValidate + * @param {string} typeofProp + * @returns {import("../../utils/types.mjs").Validator} + */ +export function validatorErrorMsgGenerator(propToValidate, typeofProp) { + const typeofExpected = { + port: 'number', + options: 'object', + 'options.useCors': 'boolean', + handler: 'function', + middleware: 'function', + }[propToValidate]; + + return { + isValid: false, + errorMsg: `"${propToValidate}" property should be typeof ${typeofExpected} but ${typeofProp} was given`, + }; +} diff --git a/src/validator/validatorChain.mjs b/src/validator/validatorChain.mjs new file mode 100644 index 0000000..706a811 --- /dev/null +++ b/src/validator/validatorChain.mjs @@ -0,0 +1,35 @@ +export class ValidatorChain { + constructor() { + this.first = null; + this.last = null; + /** @type Array */ + this.validators = []; + } + + /** + * @param {import("../../utils/types.mjs").ValidatorClass} validator + * @returns + */ + add(validator) { + if (!this.first) { + this.first = validator; + this.last = validator; + } + + this.validators.push(validator.constructor.name); + + if (this.validators.length > 1) { + this.last.setNextValidator(validator); + this.last = validator; + } + + return this; + } + + /** + * @returns {import("../../utils/types.mjs").ValidatorClass} + */ + startValidation() { + return this.first; + } +} diff --git a/utils/types.mjs b/utils/types.mjs new file mode 100644 index 0000000..6b564a6 --- /dev/null +++ b/utils/types.mjs @@ -0,0 +1,55 @@ +/** + * @typedef {Object} Options + * + * @property {boolean} useCors + */ + +/** + * CRUD operations + * @typedef {'get' | 'put' | 'delete' | 'post' | 'patch'} Operations + */ + +/** + * Route params + * @typedef {Object} Route + * + * @property {Operations} method + * @property {string} path + * @property {(req: import("express").Request, resp: import("express").Response) => void} handler + */ + +/** + * Validator return Object + * @typedef {Object} ValidatorClass + * + * @property {(param: Params) => Validator} validate + * @property {(param: ValidatorClass) => void} setNextValidator + */ + +/** + * Returns if model is valid + * @typedef {Object} Validator + * + * @property {boolean} isValid + * @property {string} [errorMsg] + */ + +/** + * Returns if model is valid + * @typedef {Object} ValidatorChain + * + * @property {boolean} isValid + * @property {string} [errorMsg] + */ + +/** + * Server and App params + * @typedef {Object} Params + * + * @property {number} [port] + * @property {Array<(req: import("express").Request, resp: import("express").Response, next: import("express").NextFunction) => void>} [middleware] + * @property {Options} [options] + * @property {Array} [routes] + */ + +export {};