├── .circleci └── config.yml ├── .eslintrc.json ├── .gitignore ├── README.md ├── index.js ├── jest.config.json ├── jsconfig.json ├── package.json └── src ├── cli ├── app │ ├── __tests__ │ │ └── index.js │ ├── index.js │ ├── project.js │ └── template │ │ ├── .gitignore.template │ │ ├── app.config.template.json │ │ ├── jsconfig.template.json │ │ ├── loader │ │ └── package.template.json ├── file-maker │ ├── __tests__ │ │ └── index.js │ ├── index.js │ └── templates │ │ ├── controller │ │ └── middleware └── index.js ├── core ├── __tests__ │ ├── config.js │ ├── controller.js │ ├── middleware.js │ ├── request.js │ ├── response.js │ ├── route.js │ └── server.js ├── config.js ├── controller.js ├── injector.js ├── middleware.js ├── request.js ├── response.js ├── route │ ├── decorator.js │ ├── path.js │ ├── proxies.js │ ├── register.js │ └── resolver.js └── server.js └── helpers ├── __tests__ ├── clean-up.js ├── middleware-runner.js ├── pathToRegex.js └── string.js ├── clean-up.js ├── cli.js ├── compilers ├── __tests__ │ └── index.js ├── engines │ ├── __tests__ │ │ └── javascript.js │ └── javascript.js └── index.js ├── env.js ├── file.js ├── middleware-runner.js ├── package.js ├── pathToRegex.js ├── string.js └── template.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build: 4 | working_directory: ~/node-api-framework 5 | docker: 6 | - image: circleci/node:10.0.0 # for the CircleCI "Convenience" image 7 | - image: node:10.0.0 # for the Docker Library image 8 | 9 | steps: 10 | # Checkout to project directory 11 | - checkout 12 | 13 | # Run commands 14 | - run: 15 | name: Install dependencies 16 | command: node -v && npm install 17 | - run: 18 | name: Run test files 19 | command: 'npm test' 20 | 21 | # Report test status 22 | - run: 23 | name: Uploading coverage to codecov 24 | command: bash <(curl -s https://codecov.io/bash) -t c84090c8-fb23-4a32-ae23-9cf5692cc8f5 25 | 26 | - store_artifacts: 27 | path: coverage 28 | 29 | - store_test_results: 30 | path: coverage 31 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "jest": true 7 | }, 8 | "extends": "standard", 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly" 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 2018 15 | }, 16 | "rules": { 17 | "standard/no-callback-literal": 0, 18 | "node/no-deprecated-api": 0, 19 | "no-cond-assign": 0 20 | } 21 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | package-lock.json 4 | 5 | .DS_Store 6 | 7 | coverage 8 | 9 | .jest-cache 10 | 11 | yarn.lock 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kasky 2 | 3 | A light weight, easy to use web framework for [node](http://nodejs.org) 4 | 5 | [![CircleCI](https://circleci.com/gh/danprocoder/kasky.svg?style=svg)](https://circleci.com/gh/danprocoder/kasky) 6 | [![codecov](https://codecov.io/gh/danprocoder/kasky/branch/master/graph/badge.svg)](https://codecov.io/gh/danprocoder/kasky) 7 | [![tested with jest](https://img.shields.io/badge/tested_with-jest-99424f.svg)](https://jest.io) 8 | [![NPM Version](https://img.shields.io/npm/v/kasky)](https://www.npmjs.com/package/kasky) 9 | [![NPM Downloads](https://img.shields.io/npm/dm/kasky)](https://www.npmjs.com/package/kasky) 10 | 11 | ## Requirements 12 | * [Node](http://nodejs.org) >= 10 13 | 14 | ## Installation 15 | Using npm: 16 | ```bash 17 | $ npm install -g kasky 18 | ``` 19 | 20 | Using yarn: 21 | ```bash 22 | $ yarn global add kasky 23 | ``` 24 | 25 | 26 | ## Documentation 27 | Read documentation [here](https://danprocoder.github.io/kasky) 28 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const Controller = require('./src/core/controller') 2 | const Middleware = require('./src/core/middleware') 3 | const Route = require('./src/core/route/proxies') 4 | 5 | module.exports = { 6 | Controller, 7 | Middleware, 8 | Route 9 | } 10 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "transform": { 4 | "^.+\\.js$": "babel-jest" 5 | }, 6 | "collectCoverageFrom": [ 7 | "/src/**/**.js", 8 | "!/src/**/__tests__/**/*.js" 9 | ] 10 | } -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "typeAcquisition": { 3 | "include": [ 4 | "jest" 5 | ] 6 | } 7 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kasky", 3 | "version": "1.0.5", 4 | "description": "A light weight, easy to use web framework for node.js", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest --coverage --runInBand --config=jest.config.json", 8 | "test:clear-cache": "jest --clearCache", 9 | "lint:check": "eslint ./src", 10 | "lint:fix": "eslint --fix ./src" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/danprocoder/kasky" 15 | }, 16 | "author": "Daniel Austin", 17 | "license": "ISC", 18 | "dependencies": { 19 | "@babel/core": "^7.6.4", 20 | "@babel/node": "^7.6.3", 21 | "@babel/plugin-proposal-decorators": "^7.6.0", 22 | "@babel/plugin-transform-runtime": "^7.6.2", 23 | "@babel/polyfill": "^7.6.0", 24 | "@babel/preset-env": "^7.6.3", 25 | "@babel/register": "^7.6.2", 26 | "@babel/runtime": "^7.6.3", 27 | "babel-preset-minify": "^0.5.1", 28 | "chalk": "^2.4.2", 29 | "coffeescript": "^2.4.1", 30 | "glob": "^7.1.5", 31 | "jsonwebtoken": "^8.5.1", 32 | "typescript": "^3.6.4" 33 | }, 34 | "bin": "src/cli/index.js", 35 | "devDependencies": { 36 | "eslint": "^6.6.0", 37 | "eslint-config-standard": "^14.1.0", 38 | "eslint-plugin-import": "^2.18.2", 39 | "eslint-plugin-node": "^10.0.0", 40 | "eslint-plugin-promise": "^4.2.1", 41 | "eslint-plugin-standard": "^4.0.1", 42 | "husky": "^3.0.9", 43 | "jest": "^24.9.0", 44 | "jest-extended": "^0.11.2" 45 | }, 46 | "husky": { 47 | "hooks": { 48 | "pre-commit": "npm run lint:check && npm test" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/cli/app/__tests__/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const fileHelper = require('../../../helpers/file') 4 | const config = require('../../../core/config') 5 | const compilerMaker = require('../../../helpers/compilers') 6 | const env = require('../../../helpers/env') 7 | const app = require('..') 8 | 9 | const mockCompilerSpy = jest.fn() 10 | 11 | function MockCompiler (...args) { 12 | mockCompilerSpy(...args) 13 | } 14 | MockCompiler.prototype.compile = () => { 15 | return Promise.resolve() 16 | } 17 | 18 | describe('Test cli/app/index.js', () => { 19 | let configGetSpy 20 | 21 | beforeAll(() => { 22 | jest.spyOn(config, 'load').mockImplementation(() => {}) 23 | jest.spyOn(MockCompiler.prototype, 'compile') 24 | }) 25 | 26 | afterAll(() => { 27 | jest.restoreAllMocks() 28 | }) 29 | 30 | describe('Test runBuild()', () => { 31 | beforeAll(() => { 32 | jest.spyOn(fileHelper, 'readString').mockImplementation(() => 'Loader template') 33 | jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}) 34 | }) 35 | 36 | afterEach(() => { 37 | configGetSpy.mockRestore() 38 | jest.clearAllMocks() 39 | }) 40 | 41 | it('should throw an error if no compiler was found for the specified language', () => { 42 | configGetSpy = jest.spyOn(config, 'get') 43 | configGetSpy.mockImplementation(() => '$non-existent-language') 44 | 45 | expect(() => 46 | app.runBuild('/src/path', '/dst/path') 47 | ).toThrow('No compiler found for $non-existent-language') 48 | }) 49 | 50 | it('should call compiler.compile() with the right parameters', () => { 51 | const spy = jest.spyOn(compilerMaker, 'getLanguageCompiler') 52 | spy.mockImplementation(() => MockCompiler) 53 | 54 | configGetSpy = jest.spyOn(config, 'get') 55 | configGetSpy.mockImplementation(() => 'javascript') 56 | 57 | return app.runBuild('/src/path', '/dst/path').then(() => { 58 | // Call with empty object is not in production 59 | expect(mockCompilerSpy).toHaveBeenCalledWith({}) 60 | 61 | expect(MockCompiler.prototype.compile).toHaveBeenCalledTimes(1) 62 | expect(MockCompiler.prototype.compile).toHaveBeenCalledWith('/src/path', '/dst/path') 63 | }) 64 | }) 65 | 66 | it('should pass option minify if compile is called in production mode', () => { 67 | const envSpy = jest.spyOn(env, 'getCurrentEnvironment') 68 | envSpy.mockImplementation(() => 'production') 69 | 70 | configGetSpy = jest.spyOn(config, 'get') 71 | configGetSpy.mockImplementation(() => 'javascript') 72 | 73 | return app.runBuild('/src/path', '/dst/path', true).then(() => { 74 | // Call with minify option if in production 75 | expect(mockCompilerSpy).toHaveBeenCalledWith({ minify: true }) 76 | 77 | envSpy.mockRestore() 78 | }) 79 | }) 80 | 81 | it('should create a loader file after compilation (in development)', () => { 82 | const envSpy = jest.spyOn(env, 'getCurrentEnvironment') 83 | envSpy.mockImplementation(() => 'production') 84 | 85 | configGetSpy = jest.spyOn(config, 'get') 86 | configGetSpy.mockImplementation(() => 'javascript') 87 | 88 | return app.runBuild('/src/path', '/dst/path') 89 | .then((buildDir) => { 90 | expect(fs.writeFileSync).toHaveBeenCalledWith( 91 | path.join(buildDir, 'loader.js'), 'Loader template' 92 | ) 93 | 94 | envSpy.mockRestore() 95 | }) 96 | }) 97 | 98 | it('should create a loader file after compilation (in production)', () => { 99 | const envSpy = jest.spyOn(env, 'getCurrentEnvironment') 100 | envSpy.mockImplementation(() => 'production') 101 | 102 | configGetSpy = jest.spyOn(config, 'get') 103 | configGetSpy.mockImplementation(() => 'javascript') 104 | 105 | return app.runBuild('/src/path', '/dst/path', true) 106 | .then((buildDir) => { 107 | expect(fs.writeFileSync).toHaveBeenCalledWith( 108 | path.join(buildDir, 'loader.js'), 'Loader template' 109 | ) 110 | 111 | envSpy.mockRestore() 112 | }) 113 | }) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /src/cli/app/index.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk') 2 | const fs = require('fs') 3 | const path = require('path') 4 | const server = require('../../core/server') 5 | const config = require('../../core/config') 6 | const cli = require('../../helpers/cli') 7 | const project = require('./project') 8 | const env = require('../../helpers/env') 9 | const cleanUp = require('../../helpers/clean-up') 10 | const compilerFactory = require('../../helpers/compilers') 11 | const fileHelper = require('../../helpers/file') 12 | 13 | /** 14 | * @return {string} Returns the absolute path to the application's source files. 15 | */ 16 | function getAppRootDir () { 17 | const rootDir = config.get('rootDir') || 'src' 18 | return path.join(process.cwd(), rootDir) 19 | } 20 | 21 | /** 22 | * 23 | * @param {string} src Directory in project to compile to build folder. 24 | * @param {string} dst The directory to save the compiled files. 25 | * @param {boolean} production Whether to run build in production mode or not. 26 | * 27 | * @return {Promise} A promise to build the app. 28 | */ 29 | function runBuild (src, dst, production = false) { 30 | const language = config.get('language') || 'javascript' 31 | const Compiler = compilerFactory.getLanguageCompiler(language) 32 | if (Compiler === null) { 33 | throw new Error(`No compiler found for ${language}`) 34 | } 35 | 36 | const compilerOptions = {} 37 | if (production) { 38 | compilerOptions.minify = true 39 | } 40 | 41 | return new Compiler(compilerOptions) 42 | .compile(src, dst) 43 | .then(() => { 44 | // Insert loader file. 45 | const loader = fileHelper.readString(path.join(__dirname, '/template/loader')) 46 | fs.writeFileSync(path.join(dst, 'loader.js'), loader) 47 | 48 | return dst 49 | }) 50 | } 51 | 52 | /** 53 | * @return {string} Return the path to the production build directory of 54 | * the app. 55 | */ 56 | function getProductionBuildFolder () { 57 | return config.get('buildDir') || 'build' 58 | } 59 | 60 | /** 61 | * 62 | * @param {*} envType The enviroment type is app should run on: production, 63 | * development or test. 64 | * 65 | * @return {Promise} Full absolute path to the app's build directory. 66 | * This is where is app will be served from. 67 | */ 68 | function beforeServer (envType) { 69 | if (envType === 'production') { 70 | return new Promise((resolve) => { 71 | const buildFolder = getProductionBuildFolder() 72 | const buildDir = path.join(process.cwd(), buildFolder) 73 | if (!fs.existsSync(buildDir)) { 74 | throw new Error( 75 | 'build folder was not found. Run `npm build` to build the app.' 76 | ) 77 | } 78 | 79 | resolve(buildDir) 80 | }) 81 | } 82 | 83 | // Create a temporary build folder for test and development mode. 84 | const appPackage = require(path.join(process.cwd(), 'package.json')) 85 | return fileHelper.createCacheDir( 86 | appPackage.name, 87 | 'build' 88 | ) 89 | .then((tmpDir) => { 90 | cleanUp.addDir(tmpDir) 91 | 92 | let rootDir = config.get('rootDir') 93 | if (!rootDir) { 94 | throw new Error( 95 | 'Please specify your project\'s root directory in ' + 96 | 'your app.config.json' 97 | ) 98 | } 99 | 100 | rootDir = path.join(process.cwd(), rootDir) 101 | return runBuild(rootDir, tmpDir) 102 | }) 103 | .then((buildDir) => { 104 | // Create symlinks for project's node modules 105 | const linkTo = path.join(process.cwd(), 'node_modules') 106 | const linkPath = path.join(buildDir, 'node_modules') 107 | if (!fs.existsSync(linkPath) && fs.existsSync(linkTo)) { 108 | fs.symlinkSync(linkTo, linkPath, 'dir') 109 | } 110 | 111 | return buildDir 112 | }) 113 | } 114 | 115 | exports.process = function (command, args) { 116 | switch (command) { 117 | case 'init': { 118 | const name = args[0] 119 | if (!name) { 120 | cli.log('Project name is required.') 121 | } else { 122 | new project.Project(name).make() 123 | } 124 | 125 | break 126 | } 127 | 128 | case 'build': 129 | config.load() 130 | 131 | cli.log('Building...') 132 | 133 | runBuild( 134 | getAppRootDir(), 135 | path.join(process.cwd(), getProductionBuildFolder()), 136 | cli.hasFlag(args, '--prod') 137 | ) 138 | .then(() => { 139 | console.log('Build finished!') 140 | }) 141 | 142 | break 143 | 144 | case 'start-server': { 145 | config.load() 146 | 147 | const envType = env.getCurrentEnvironment() 148 | 149 | beforeServer(envType) 150 | .then((buildDir) => { 151 | const appLoader = require(`${buildDir}/loader.js`) 152 | 153 | const controllersPath = config.get('controllersPath') 154 | return appLoader.load(path.join(buildDir, controllersPath)) 155 | }) 156 | .then(() => { 157 | cli.log('Starting server...') 158 | 159 | let port = cli.extractParam(args, 'port') 160 | if (!port) { 161 | port = process.env.PORT || 0 162 | } 163 | new server.Server({ port }) 164 | .start((options) => { 165 | cli.log('Server running at', 166 | `${chalk.green(`127.0.0.1:${options.port}`)}.`, 167 | 'Use Ctrl + C to stop the server.') 168 | }) 169 | }) 170 | .catch((error) => { 171 | console.log(chalk.red(error)) 172 | console.log(cli.stackTrace(error.stack)) 173 | }) 174 | 175 | break 176 | } 177 | } 178 | } 179 | 180 | process.on('exit', () => cleanUp.cleanUp()) 181 | 182 | if (env.getCurrentEnvironment() === 'test') { 183 | exports.runBuild = runBuild 184 | } 185 | -------------------------------------------------------------------------------- /src/cli/app/project.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const chalk = require('chalk') 3 | const path = require('path') 4 | const packageJson = require('../../helpers/package') 5 | const cli = require('../../helpers/cli') 6 | const template = require('../../helpers/template') 7 | 8 | /** 9 | * 10 | * @param {string} name The name of the new project to create. 11 | */ 12 | function Project (name) { 13 | this.name = name 14 | 15 | this.folders = [ 16 | 'src/controllers', 17 | 'src/middlewares' 18 | ] 19 | 20 | this.files = [ 21 | { 22 | target: 'package.json', 23 | template: 'package.template.json', 24 | data: { 25 | name, 26 | framework: packageJson.name, 27 | frameworkVersion: packageJson.version 28 | } 29 | }, 30 | { 31 | target: 'jsconfig.json', 32 | template: 'jsconfig.template.json' 33 | }, 34 | { 35 | target: 'app.config.json', 36 | template: 'app.config.template.json' 37 | }, 38 | { 39 | target: '.gitignore', 40 | template: '.gitignore.template' 41 | } 42 | ] 43 | } 44 | 45 | Project.prototype.make = function () { 46 | // Create folders. 47 | this.folders.forEach((folder) => { 48 | fs.mkdirSync(path.join(process.cwd(), this.name, folder), 49 | { recursive: true }) 50 | }) 51 | 52 | // Create project files. 53 | this.files.forEach((file) => { 54 | const targetFilePath = path.join(process.cwd(), this.name, file.target) 55 | cli.log('Generating', chalk.gray(targetFilePath)) 56 | 57 | template.insertFile( 58 | path.join(__dirname, 'template', file.template), 59 | targetFilePath, 60 | file.data 61 | ) 62 | 63 | cli.log(chalk.green('Generated'), chalk.gray(targetFilePath)) 64 | }) 65 | } 66 | 67 | exports.Project = Project 68 | -------------------------------------------------------------------------------- /src/cli/app/template/.gitignore.template: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | build 4 | 5 | package-lock.json 6 | 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /src/cli/app/template/app.config.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "javascript", 3 | "rootDir": "./src", 4 | "controllersPath": "./controllers", 5 | "middlewaresPath": "./middlewares" 6 | } 7 | -------------------------------------------------------------------------------- /src/cli/app/template/jsconfig.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true 4 | } 5 | } -------------------------------------------------------------------------------- /src/cli/app/template/loader: -------------------------------------------------------------------------------- 1 | const glob = require('glob') 2 | const path = require('path') 3 | 4 | exports.load = function (controllersPath) { 5 | return new Promise((resolve, reject) => { 6 | glob(path.join(controllersPath, '**', '*.js'), null, (err, files) => { 7 | if (err) { 8 | reject('Unable to load app.') 9 | } 10 | 11 | resolve(files.map(filepath => require(filepath))) 12 | }) 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /src/cli/app/template/package.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "%{name}", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "%{framework} build", 8 | "dev": "%{framework} start-server", 9 | "start": "%{framework} start-server --prod" 10 | }, 11 | "license": "MIT", 12 | "dependencies": { 13 | "@babel/runtime": "^7.6.3", 14 | "glob": "^7.1.6", 15 | "%{framework}": "^%{frameworkVersion}" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/cli/file-maker/__tests__/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const string = require('../../../helpers/string') 4 | const fileMaker = require('..') 5 | const config = require('../../../core/config') 6 | const template = require('../../../helpers/template') 7 | const cli = require('../../../helpers/cli') 8 | const packageJson = require('../../../helpers/package') 9 | 10 | const testConfig = { 11 | rootDir: '/', 12 | middlewaresPath: '/path/to/middlewares', 13 | controllersPath: '/path/to/controllers' 14 | } 15 | 16 | const templateDir = path.join(process.cwd(), 'src/cli/file-maker/templates') 17 | 18 | describe('Test commands to create generate files', () => { 19 | beforeAll(() => { 20 | jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {}) 21 | jest.spyOn(template, 'insertFile').mockImplementation(() => {}) 22 | 23 | // Suppress log functions 24 | jest.spyOn(cli, 'log').mockImplementation(() => {}) 25 | jest.spyOn(cli, 'error').mockImplementation(() => {}) 26 | 27 | jest.spyOn(config, 'load').mockImplementation(() => {}) 28 | jest.spyOn(config, 'get').mockImplementation((key) => testConfig[key]) 29 | 30 | jest.spyOn(string, 'validateClassname') 31 | }) 32 | 33 | afterAll(() => jest.restoreAllMocks()) 34 | 35 | describe('Test feature to create a middleware file', () => { 36 | afterEach(() => jest.clearAllMocks()) 37 | 38 | it('should call function to create a middleware file.', () => { 39 | const spy = jest.spyOn(fileMaker, 'makeMiddlewareFile') 40 | spy.mockImplementation(() => {}) 41 | 42 | fileMaker.process('make:middleware', ['--name=ValidateUser']) 43 | 44 | // Ensure configuration file is loaded first. 45 | expect(config.load).toHaveBeenCalled() 46 | 47 | expect(fileMaker.makeMiddlewareFile).toHaveBeenCalledWith(['--name=ValidateUser']) 48 | 49 | spy.mockRestore() 50 | }) 51 | 52 | it('should throw an error if --name options is not specified', () => { 53 | expect( 54 | () => fileMaker.makeMiddlewareFile([]) 55 | ).toThrow( 56 | 'Middleware classname not specified. ' + 57 | 'Use the --name=YourMiddleware option to specify the classname.' 58 | ) 59 | }) 60 | 61 | it('should create a new middleware file in the middlewares directory', () => { 62 | fileMaker.process('make:middleware', ['--name=ValidateUser']) 63 | 64 | // Must validate classname 65 | expect(string.validateClassname).toHaveBeenCalledWith('ValidateUser') 66 | 67 | expect(fs.mkdirSync).toHaveBeenCalledWith(testConfig.middlewaresPath, { recursive: true }) 68 | 69 | const templatePath = path.join(templateDir, 'middleware') 70 | const outputPath = path.join( 71 | testConfig.middlewaresPath, 72 | `${string.camelCaseToFilename('ValidateUser')}.js` 73 | ) 74 | expect(template.insertFile).toHaveBeenCalledWith( 75 | templatePath, outputPath, { name: 'ValidateUser' } 76 | ) 77 | }) 78 | 79 | it('should create a new middleware file in a subdirectory', () => { 80 | const filename = 'validate-user.js' 81 | const className = 'ValidateUser' 82 | const migrationDir = path.join(testConfig.middlewaresPath, 'sub/dir') 83 | 84 | fileMaker.process('make:middleware', [`--name=sub/dir/${className}`]) 85 | 86 | expect(string.validateClassname).toHaveBeenCalledWith(className) 87 | 88 | expect(fs.mkdirSync).toHaveBeenCalledWith(migrationDir, { recursive: true }) 89 | 90 | expect(template.insertFile).toHaveBeenCalledWith( 91 | path.join(templateDir, 'middleware'), 92 | path.join(migrationDir, filename), 93 | { name: className } 94 | ) 95 | }) 96 | }) 97 | 98 | describe('Test feature to create a controller file', () => { 99 | afterEach(() => jest.clearAllMocks()) 100 | 101 | it('should call function to create a controller file', () => { 102 | const spy = jest.spyOn(fileMaker, 'makeControllerFile') 103 | spy.mockImplementation(() => {}) 104 | 105 | fileMaker.process('make:controller', ['--name=UsersController']) 106 | 107 | expect(config.load).toHaveBeenCalledTimes(1) 108 | 109 | spy.mockRestore() 110 | }) 111 | 112 | it('should throw an error if a controller name was not supplied', () => { 113 | expect( 114 | () => fileMaker.makeControllerFile([]) 115 | ).toThrow('Name of controller class not supplied') 116 | expect(fs.mkdirSync).toHaveBeenCalledTimes(0) 117 | }) 118 | 119 | it('should create a new controller file', () => { 120 | const fileName = 'my-new-controller.js' 121 | const className = 'MyNewController' 122 | 123 | fileMaker.makeControllerFile(['--name=MyNewController']) 124 | 125 | // Must validate classname 126 | expect(string.validateClassname).toHaveBeenCalledWith('MyNewController') 127 | 128 | expect(fs.mkdirSync).toHaveBeenCalledWith( 129 | testConfig.controllersPath, { recursive: true } 130 | ) 131 | 132 | expect(template.insertFile).toHaveBeenCalledWith( 133 | path.join(templateDir, 'controller'), 134 | path.join(testConfig.controllersPath, fileName), 135 | { package: packageJson.name, name: className } 136 | ) 137 | }) 138 | 139 | it('should create a new controller file in a subdirectory', () => { 140 | const fileName = 'my-new-controller.js' 141 | const className = 'MyNewController' 142 | const controllerDir = path.join(testConfig.controllersPath, 'sub/dir') 143 | 144 | fileMaker.makeControllerFile([`--name=/sub/dir/${className}`]) 145 | 146 | // Must validate classname 147 | expect(string.validateClassname).toHaveBeenCalledWith(className) 148 | 149 | // Should attempt to create the directory to save the controller class 150 | expect(fs.mkdirSync).toHaveBeenCalledWith( 151 | path.join(controllerDir), 152 | { recursive: true } 153 | ) 154 | 155 | // Should insert the template file 156 | expect(template.insertFile).toHaveBeenCalledWith( 157 | path.join(templateDir, 'controller'), 158 | path.join(controllerDir, fileName), 159 | { package: packageJson.name, name: className } 160 | ) 161 | }) 162 | }) 163 | }) 164 | -------------------------------------------------------------------------------- /src/cli/file-maker/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const chalk = require('chalk') 4 | const packageJson = require('../../helpers/package') 5 | const config = require('../../core/config') 6 | const template = require('../../helpers/template') 7 | const string = require('../../helpers/string') 8 | const cli = require('../../helpers/cli') 9 | 10 | module.exports = { 11 | 12 | /** 13 | * Called by make:controller command to create a controller file. 14 | * 15 | * @param {array} args Command line arguments after make:controller. 16 | */ 17 | makeControllerFile (args) { 18 | const name = cli.extractParam(args, 'name') 19 | if (!name) { 20 | throw new Error('Name of controller class not supplied') 21 | } 22 | 23 | const className = path.basename(name) 24 | const subDir = path.dirname(name).replace(/^\/+|\/+$/g, '') 25 | 26 | string.validateClassname(className) 27 | 28 | const fileName = `${string.camelCaseToFilename(className)}.js` 29 | const controllersPath = path.join(config.get('rootDir'), config.get('controllersPath'), subDir) 30 | fs.mkdirSync(controllersPath, { recursive: true }) 31 | 32 | cli.log('Generating', path.join(controllersPath, fileName)) 33 | 34 | template.insertFile( 35 | path.join(__dirname, 'templates/controller'), 36 | path.join(controllersPath, fileName), 37 | { package: packageJson.name, name: className } 38 | ) 39 | 40 | cli.log(chalk.green('Generated'), path.join(controllersPath, fileName)) 41 | }, 42 | 43 | /** 44 | * 45 | * @param {string[]} args 46 | */ 47 | makeMiddlewareFile (args) { 48 | const name = cli.extractParam(args, 'name') 49 | if (!name) { 50 | throw new Error( 51 | 'Middleware classname not specified. ' + 52 | 'Use the --name=YourMiddleware option to specify the classname.' 53 | ) 54 | } 55 | 56 | const className = path.basename(name) 57 | const subDir = path.dirname(name).replace(/^\/+|\/+$/g, '') 58 | 59 | string.validateClassname(className) 60 | 61 | const middlewaresPath = path.join(config.get('rootDir'), config.get('middlewaresPath'), subDir) 62 | fs.mkdirSync(middlewaresPath, { recursive: true }) 63 | 64 | const targetFilePath = path.join( 65 | middlewaresPath, string.camelCaseToFilename(className).concat('.js') 66 | ) 67 | 68 | cli.log('Generating', chalk.gray(targetFilePath)) 69 | 70 | template.insertFile( 71 | path.join(__dirname, 'templates/middleware'), 72 | targetFilePath, 73 | { name: className } 74 | ) 75 | 76 | cli.log(chalk.green('Generated'), chalk.gray(targetFilePath)) 77 | }, 78 | 79 | process (command, args) { 80 | config.load() 81 | 82 | switch (command) { 83 | case 'make:controller': 84 | this.makeControllerFile(args) 85 | 86 | break 87 | 88 | case 'make:middleware': 89 | this.makeMiddlewareFile(args) 90 | 91 | break 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/cli/file-maker/templates/controller: -------------------------------------------------------------------------------- 1 | import { Controller } from '%{package}'; 2 | 3 | @Controller() 4 | class %{name} {} 5 | 6 | export default %{name}; 7 | -------------------------------------------------------------------------------- /src/cli/file-maker/templates/middleware: -------------------------------------------------------------------------------- 1 | import { Middleware } from 'kasky'; 2 | 3 | @Middleware() 4 | class %{name} { 5 | handle(req, res, next) { 6 | next(); 7 | } 8 | } 9 | 10 | export default %{name}; 11 | -------------------------------------------------------------------------------- /src/cli/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const chalk = require('chalk') 4 | 5 | const fileMakerCommands = /^make:(controller|middleware)$/ 6 | const appCommands = /^(init|build|start-server)$/ 7 | 8 | const args = process.argv.slice(2) 9 | if (args.length >= 1) { 10 | const command = args[0] 11 | let resolver 12 | 13 | if (command.match(fileMakerCommands)) { 14 | resolver = require('./file-maker') 15 | } else if (command.match(appCommands)) { 16 | resolver = require('./app') 17 | } else { 18 | console.log(chalk.red(`Unknown command '${command}'`)) 19 | } 20 | 21 | if (resolver) { 22 | try { 23 | resolver.process(command, args.slice(1)) 24 | } catch (err) { 25 | console.log(chalk.red(err)) 26 | } 27 | } 28 | } else { 29 | console.error(chalk.red('No command specified')) 30 | } 31 | -------------------------------------------------------------------------------- /src/core/__tests__/config.js: -------------------------------------------------------------------------------- 1 | const config = require('../config') 2 | 3 | describe('Test src/core/config.js', () => { 4 | it('it should inject all values', () => { 5 | const parsed = new config.Parser({ 6 | project: { 7 | config: { 8 | rootDir: '{rootDir}', 9 | somePath: '{somePath}' 10 | }, 11 | developer: { 12 | drinks: '{tea.name} ({tea.type})' 13 | } 14 | }, 15 | tea: { 16 | name: '{var.name}', 17 | type: '{var.type} tea' 18 | }, 19 | var: { 20 | name: 'lipton', 21 | type: 'green' 22 | }, 23 | rootDir: '/src', 24 | somePath: '{rootDir}/somePath', 25 | someOtherPath: '{rootDir}{somePath}/end', 26 | o: '{project.config}', 27 | unknown: '{does.not.exists}' 28 | }).parse() 29 | 30 | expect(parsed.project.config.rootDir).toEqual('/src') 31 | expect(parsed.project.config.somePath).toEqual('/src/somePath') 32 | expect(parsed.project.developer.drinks).toEqual('lipton (green tea)') 33 | expect(parsed.tea.name).toEqual('lipton') 34 | expect(parsed.tea.type).toEqual('green tea') 35 | expect(parsed.rootDir).toEqual('/src') 36 | expect(parsed.somePath).toEqual('/src/somePath') 37 | expect(parsed.someOtherPath).toEqual('/src/src/somePath/end') 38 | expect(parsed.o).toEqual(parsed.project.config) 39 | expect(parsed.unknown).toEqual('{does.not.exists}') 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/core/__tests__/controller.js: -------------------------------------------------------------------------------- 1 | const controllerDecorator = require('../controller') 2 | 3 | describe('Test @Controller() Decorator', () => { 4 | let Controller 5 | 6 | beforeEach(() => { 7 | Controller = function () {} 8 | }) 9 | 10 | it('should set _baseRoute property of controller instance', () => { 11 | const Decorated = controllerDecorator({ baseRoute: '/api/v1.0' })(Controller) 12 | expect(new Decorated()._baseRoute).toEqual('/api/v1.0') 13 | }) 14 | 15 | it('should inject ModelA and ModelB', () => { 16 | function ModelA () {} 17 | function ModelB () {} 18 | 19 | const decorated = new (controllerDecorator({ 20 | use: { modelA: ModelA, modelB: ModelB } 21 | })(Controller))() 22 | expect(decorated._baseRoute).toBeFalsy() 23 | expect(decorated.modelA).toBeInstanceOf(ModelA) 24 | expect(decorated.modelB).toBeInstanceOf(ModelB) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/core/__tests__/middleware.js: -------------------------------------------------------------------------------- 1 | const middlewareDecorator = require('../middleware') 2 | 3 | describe('Test the @Middleware() Decorator', () => { 4 | it('should throw an error if the middleware doesn\'t have a handle() method', () => { 5 | function TestMiddleware () {} 6 | expect(() => 7 | middlewareDecorator()(TestMiddleware) 8 | ).toThrow('Middleware should contain a handle() method') 9 | }) 10 | 11 | it('should not throw an error if the middleware has a handle() method', () => { 12 | function TestMiddleware () {} 13 | TestMiddleware.prototype.handle = function (req, res, next) {} 14 | 15 | expect(() => 16 | middlewareDecorator()(TestMiddleware) 17 | ).not.toThrow() 18 | }) 19 | 20 | it('should inject models', () => { 21 | function TestMiddleware () {} 22 | TestMiddleware.prototype.handle = function (req, res, next) {} 23 | 24 | // Models 25 | function ModelA () {} 26 | function ModelB () {} 27 | 28 | // Decorate the middleware 29 | middlewareDecorator({ 30 | use: { 31 | modelA: ModelA, 32 | modelB: ModelB 33 | } 34 | })(TestMiddleware) 35 | 36 | const middleware = new TestMiddleware() 37 | expect(middleware.modelA).toBeInstanceOf(ModelA) 38 | expect(middleware.modelB).toBeInstanceOf(ModelB) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/core/__tests__/request.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken') 2 | const Request = require('../request') 3 | 4 | function createMockNodeRequest (headers, url) { 5 | return { 6 | headers, url 7 | } 8 | } 9 | 10 | describe('Test the request object', () => { 11 | let req 12 | 13 | beforeAll(() => { 14 | const nodeReq = createMockNodeRequest( 15 | { 16 | 'content-type': 'application/json' 17 | }, 18 | 'http://domain.net/product?action=update&id=5' 19 | ) 20 | req = new Request(nodeReq, { section: 'product' }, '{"name":"Product Name"}') 21 | }) 22 | 23 | it('should return value for content-type header', () => { 24 | expect(req.header('content-type')).toEqual('application/json') 25 | }) 26 | 27 | it('should return the header object', () => { 28 | expect(req.header()).toEqual({ 'content-type': 'application/json' }) 29 | }) 30 | 31 | it('should return null if header was not sent by client', () => { 32 | expect(req.header('unknown-header')).toBeNull() 33 | }) 34 | 35 | it('should return value for set url param', () => { 36 | expect(req.param('section')).toEqual('product') 37 | }) 38 | 39 | it('should return the param object', () => { 40 | expect(req.param()).toEqual({ section: 'product' }) 41 | }) 42 | 43 | it('should return null if param was not sent', () => { 44 | expect(req.param('unknown-param')).toBeNull() 45 | }) 46 | 47 | it('should return value for sent query', () => { 48 | expect(req.query('action')).toEqual('update') 49 | expect(req.query('id')).toEqual('5') 50 | }) 51 | 52 | it('should return null if query was not sent', () => { 53 | expect(req.query('name')).toBeNull() 54 | }) 55 | 56 | it('should return the query object', () => { 57 | expect(req.query()).toEqual({ 58 | action: 'update', 59 | id: '5' 60 | }) 61 | }) 62 | 63 | it('should return value of body parameter', () => { 64 | expect(req.body('name')).toEqual('Product Name') 65 | }) 66 | 67 | it('should return the body object', () => { 68 | expect(req.body()).toEqual({ name: 'Product Name' }) 69 | }) 70 | 71 | it('should return null if body parameter was not sent', () => { 72 | expect(req.body('price')).toBeNull() 73 | }) 74 | 75 | describe('Test authBearer() method', () => { 76 | it('should return null if no authorization header was set', () => { 77 | const nodeReq = createMockNodeRequest({}, 'some/url/path') 78 | const req = new Request(nodeReq, {}, {}) 79 | 80 | expect(req.authBearer()).toBeNull() 81 | }) 82 | 83 | it('should return null if the format for the authorization header is incorrect', () => { 84 | const nodeReq = createMockNodeRequest({ 85 | authorization: 'user_authorization_token' 86 | }, 'some/url/path') 87 | const req = new Request(nodeReq, {}, {}) 88 | 89 | expect(req.authBearer()).toBeNull() 90 | }) 91 | 92 | it('should return the authorization bearer token', () => { 93 | const nodeReq = createMockNodeRequest({ 94 | authorization: 'Bearer user_authorization_token' 95 | }, 'some/url/path') 96 | const req = new Request(nodeReq, {}, {}) 97 | 98 | expect(req.authBearer()).toEqual('user_authorization_token') 99 | }) 100 | }) 101 | 102 | describe('Test authBearerJwtDecode() method', () => { 103 | it('should return null if no bearer token was set', () => { 104 | const nodeReq = createMockNodeRequest({}, 'some/url/path') 105 | const req = new Request(nodeReq, {}, {}) 106 | 107 | expect(req.authBearerJwtDecode('fake_secret')).toBeNull() 108 | }) 109 | 110 | it('should return null if jwt token is invalid', () => { 111 | const nodeReq = createMockNodeRequest({ 112 | authorization: 'Bearer fake_token' 113 | }, 'some/url/path') 114 | const req = new Request(nodeReq, {}, {}) 115 | 116 | expect(req.authBearerJwtDecode('fake_secret')).toBeNull() 117 | }) 118 | 119 | it('should return the decoded jwt payload', () => { 120 | const jwtSecret = 'some_secret_key' 121 | const token = jwt.sign({ data: 'user_data' }, jwtSecret) 122 | 123 | const nodeReq = createMockNodeRequest({ 124 | authorization: `Bearer ${token}` 125 | }, 'some/url/path') 126 | const req = new Request(nodeReq, {}, {}) 127 | 128 | expect( 129 | req.authBearerJwtDecode(jwtSecret) 130 | ).toMatchObject({ data: 'user_data' }) 131 | }) 132 | }) 133 | 134 | describe('Test headerJwtDecode() method', () => { 135 | it('should return null if the header was not set', () => { 136 | const nodeReq = createMockNodeRequest({}, '') 137 | const req = new Request(nodeReq, {}, {}) 138 | 139 | expect(req.headerJwtDecode('my-token', 'some-secret')).toBeNull() 140 | }) 141 | 142 | it('should return null if the token is invalid', () => { 143 | const nodeReq = createMockNodeRequest({ 'my-token': 'invalid_jwt_token' }, '') 144 | const req = new Request(nodeReq, {}, {}) 145 | 146 | expect(req.headerJwtDecode('my-token', 'some-secret')).toBeNull() 147 | }) 148 | 149 | it('should return the decoded jwt payload', () => { 150 | const jwtSecret = 'some_secret_key' 151 | const token = jwt.sign({ data: 'user_data' }, jwtSecret) 152 | 153 | const nodeReq = createMockNodeRequest({ 'my-token': token }, '') 154 | const req = new Request(nodeReq, {}, {}) 155 | 156 | expect( 157 | req.headerJwtDecode('my-token', 'some_secret_key') 158 | ).toMatchObject({ data: 'user_data' }) 159 | }) 160 | }) 161 | }) 162 | -------------------------------------------------------------------------------- /src/core/__tests__/response.js: -------------------------------------------------------------------------------- 1 | const Response = require('../response') 2 | 3 | const mockNodeResponse = { 4 | statusCode: null, 5 | setHeader: jest.fn(), 6 | write: jest.fn(), 7 | end: jest.fn() 8 | } 9 | 10 | describe('Test response object', () => { 11 | let res 12 | 13 | beforeAll(() => { 14 | res = new Response(mockNodeResponse) 15 | jest.spyOn(res, 'send') 16 | }) 17 | 18 | afterEach(() => { 19 | mockNodeResponse.statusCode = null 20 | jest.clearAllMocks() 21 | }) 22 | 23 | it('should send a response with the right status codes', () => { 24 | const helpers = [ 25 | ['success', 200], 26 | ['created', 201], 27 | ['notFound', 404], 28 | ['badRequest', 400], 29 | ['unauthorized', 401], 30 | ['forbidden', 403], 31 | ['internalServerError', 500] 32 | ] 33 | helpers.forEach(helper => { 34 | const [fn, code] = helper 35 | 36 | expect(res[fn]('test_data', 'test_type')).toBeUndefined() 37 | expect(res.send).toHaveBeenCalledWith(code, 'test_data', 'test_type') 38 | }) 39 | }) 40 | 41 | it('should call send() with the right parameters', () => { 42 | res.success(null, 'json') 43 | expect(res.send).toHaveBeenCalledWith(200, null, 'json') 44 | 45 | res.success('text') 46 | expect(res.send).toHaveBeenCalledWith(200, 'text', null) 47 | 48 | res.success() 49 | expect(res.send).toHaveBeenCalledWith(200, null, null) 50 | }) 51 | 52 | it('should set status code and call end()', () => { 53 | res.send(200) 54 | 55 | expect(mockNodeResponse.statusCode).toEqual(200) 56 | expect(mockNodeResponse.setHeader).toHaveBeenCalledTimes(0) 57 | expect(mockNodeResponse.write).toHaveBeenCalledTimes(0) 58 | expect(mockNodeResponse.end).toHaveBeenCalledTimes(1) 59 | }) 60 | 61 | it('should set status code, send data with type as json and call end()', () => { 62 | res.send(200, { message: 'hello' }) 63 | 64 | expect(mockNodeResponse.statusCode).toEqual(200) 65 | expect(mockNodeResponse.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json') 66 | expect(mockNodeResponse.write).toHaveBeenCalledWith(JSON.stringify({ message: 'hello' })) 67 | expect(mockNodeResponse.end).toHaveBeenCalledTimes(1) 68 | }) 69 | 70 | it('should set status code, send data with type as text and call end()', () => { 71 | res.send(200, 'hello') 72 | 73 | expect(mockNodeResponse.statusCode).toEqual(200) 74 | expect(mockNodeResponse.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain') 75 | expect(mockNodeResponse.write).toHaveBeenCalledWith('hello') 76 | expect(mockNodeResponse.end).toHaveBeenCalledTimes(1) 77 | }) 78 | 79 | it('should set status code, send data with type as html and call end()', () => { 80 | res.send(200, 'hello', 'text/html') 81 | 82 | expect(mockNodeResponse.statusCode).toEqual(200) 83 | expect(mockNodeResponse.setHeader).toHaveBeenCalledWith('Content-Type', 'text/html') 84 | expect(mockNodeResponse.write).toHaveBeenCalledWith('hello') 85 | expect(mockNodeResponse.end).toHaveBeenCalledTimes(1) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /src/core/__tests__/route.js: -------------------------------------------------------------------------------- 1 | const methodDecorator = require('../route/decorator') 2 | const register = require('../route/register') 3 | const proxy = require('../route/proxies') 4 | const Path = require('../route/path') 5 | const resolver = require('../route/resolver') 6 | const controllerDec = require('../controller') 7 | 8 | describe('Test the @Route.*() decorator', () => { 9 | describe('Test register() method', () => { 10 | afterAll(() => { 11 | register._routes = [] 12 | jest.clearAllMocks() 13 | }) 14 | 15 | it('should register a route', () => { 16 | function Controller () {} 17 | Controller.prototype.handleGet = function () {} 18 | 19 | const registered = register.register( 20 | 'GET', 21 | '/users', 22 | Controller.prototype, 23 | 'handleGet' 24 | ) 25 | expect(registered.method).toEqual('GET') 26 | expect(registered.pathname).toEqual('/users') 27 | expect(registered.resolveTo.controller).toEqual(Controller.prototype) 28 | expect(registered.resolveTo.methodName).toEqual('handleGet') 29 | expect(registered.middlewares).toEqual([]) 30 | expect(register._routes).toContain(registered) 31 | }) 32 | 33 | it('should register a new route with middlewares', () => { 34 | function Controller () {} 35 | Controller.prototype.handleGet = function () {} 36 | 37 | function MiddlewareA () {} 38 | function MiddlewareB () {} 39 | 40 | const registered = register.register( 41 | 'GET', 42 | '/users', 43 | Controller.prototype, 44 | 'handleGet', 45 | { 46 | middlewares: [MiddlewareA, MiddlewareB] 47 | } 48 | ) 49 | expect(register._routes.length).toEqual(2) 50 | expect(register._routes).toContain(registered) 51 | expect(registered.middlewares).toEqual([MiddlewareA, MiddlewareB]) 52 | }) 53 | }) 54 | 55 | describe('Test decorator', () => { 56 | beforeAll(() => { 57 | jest.spyOn(register, 'register') 58 | }) 59 | 60 | afterEach(() => { 61 | register._routes = [] 62 | jest.clearAllMocks() 63 | }) 64 | 65 | it('decorator should register a new route and return a custom descriptor', () => { 66 | function Controller () {} 67 | Controller.prototype.handleGet = function () {} 68 | 69 | const decorator = methodDecorator.getDecorator('GET', '/users') 70 | const descriptor = decorator(Controller.prototype, 'handleGet', { 71 | value: Controller.prototype.handleGet 72 | }) 73 | expect(typeof descriptor.get()).toEqual('function') 74 | expect(typeof descriptor.get().prototype).toEqual('undefined') 75 | 76 | expect(register.register).toHaveBeenCalledWith( 77 | 'GET', 78 | '/users', 79 | Controller.prototype, 80 | 'handleGet', 81 | {} // By default, a default empty object {} is passed as config 82 | ) 83 | }) 84 | 85 | it('decorator should register a new route with an array', () => { 86 | function Controller () {} 87 | Controller.prototype.handleGet = function () {} 88 | 89 | const decorator = methodDecorator.getDecorator('GET', ['/user', '/user/{id}']) 90 | decorator(Controller.prototype, 'handleGet', { 91 | value: Controller.prototype.handleGet 92 | }) 93 | 94 | expect(register._routes.length).toEqual(2) 95 | expect(register.register).toHaveBeenCalledTimes(2) 96 | expect(register.register.mock.calls).toEqual([ 97 | ['GET', '/user', Controller.prototype, 'handleGet', {}], 98 | ['GET', '/user/{id}', Controller.prototype, 'handleGet', {}] 99 | ]) 100 | }) 101 | }) 102 | 103 | describe('Test decorator proxies', () => { 104 | beforeAll(() => { 105 | jest.spyOn(methodDecorator, 'getDecorator') 106 | }) 107 | 108 | afterEach(() => { 109 | jest.clearAllMocks() 110 | }) 111 | 112 | afterAll(() => { 113 | register._routes = [] 114 | }) 115 | 116 | it('Get() proxy should return a decorator for HTTP GET method', () => { 117 | const decorator = proxy.Get('/blogs') 118 | expect(typeof decorator).toEqual('function') 119 | expect(methodDecorator.getDecorator).toHaveBeenCalledWith('GET', '/blogs') 120 | }) 121 | 122 | it('Post() proxy should return a decorator for HTTP POST method', () => { 123 | const decorator = proxy.Post('/blog') 124 | expect(typeof decorator).toEqual('function') 125 | expect(methodDecorator.getDecorator).toHaveBeenCalledWith('POST', '/blog') 126 | }) 127 | 128 | it('Patch() proxy should return a decorator for HTTP PATCH method', () => { 129 | const decorator = proxy.Patch('/blog/{id}') 130 | expect(typeof decorator).toEqual('function') 131 | expect(methodDecorator.getDecorator).toHaveBeenCalledWith('PATCH', '/blog/{id}') 132 | }) 133 | 134 | it('Put() proxy should return a decorator for HTTP PUT method', () => { 135 | const decorator = proxy.Put('/blog/{id}') 136 | expect(typeof decorator).toEqual('function') 137 | expect(methodDecorator.getDecorator).toHaveBeenCalledWith('PUT', '/blog/{id}') 138 | }) 139 | 140 | it('Delete() proxy should return a decorator for HTTP DELETE method', () => { 141 | const decorator = proxy.Delete('/blog/{id}') 142 | expect(typeof decorator).toEqual('function') 143 | expect(methodDecorator.getDecorator).toHaveBeenCalledWith('DELETE', '/blog/{id}') 144 | }) 145 | }) 146 | 147 | describe('Test route resolver without controller baseRoute', () => { 148 | let Controller 149 | 150 | beforeAll(() => { 151 | Controller = function () {} 152 | 153 | Controller.prototype.createBlog = () => {} 154 | proxy.Post('/api/v1/blog')( 155 | Controller.prototype, 156 | 'createBlog', 157 | { value: Controller.prototype.createBlog } 158 | ) 159 | 160 | Controller.prototype.getAllBlogs = () => {} 161 | proxy.Get('/api/v1/blog')( 162 | Controller.prototype, 163 | 'getAllBlogs', 164 | { value: Controller.prototype.getAllBlogs } 165 | ) 166 | 167 | Controller.prototype.deleteBlog = () => {} 168 | proxy.Delete('/api/v1/blog/{id}')( 169 | Controller.prototype, 170 | 'deleteBlog', 171 | { value: Controller.prototype.deleteBlog } 172 | ) 173 | 174 | Controller.prototype.editBlog = () => {} 175 | proxy.Patch('/api/v1/blog/{id}')( 176 | Controller.prototype, 177 | 'editBlog', 178 | { value: Controller.prototype.editBlog } 179 | ) 180 | }) 181 | 182 | afterAll(() => { 183 | register._routes = [] 184 | }) 185 | 186 | it('should return false if the route does not exists', () => { 187 | const resolved = resolver.resolve('GET', '/unknown/url/path') 188 | expect(resolved).toEqual(false) 189 | }) 190 | 191 | it('should return createBlog() method', () => { 192 | const resolved = resolver.resolve('POST', '/api/v1/blog') 193 | expect(typeof resolved).toEqual('object') 194 | // Resolve method should be bounded 195 | expect(typeof resolved.method.prototype).toEqual('undefined') 196 | }) 197 | 198 | it('should return getAllBlogs() method', () => { 199 | const resolved = resolver.resolve('GET', '/api/v1/blog') 200 | expect(typeof resolved).toEqual('object') 201 | // Resolve method should be bounded 202 | expect(typeof resolved.method.prototype).toEqual('undefined') 203 | }) 204 | 205 | it('should return deleteBlog() method', () => { 206 | const resolved = resolver.resolve('DELETE', '/api/v1/blog/6') 207 | expect(typeof resolved).toEqual('object') 208 | }) 209 | 210 | it('should return editBlog() method', () => { 211 | const resolved = resolver.resolve('PATCH', '/api/v1/blog/6') 212 | expect(typeof resolved).toEqual('object') 213 | }) 214 | }) 215 | 216 | describe('Test resolvers with controller base routes', () => { 217 | beforeAll(() => { 218 | const Controller = controllerDec({ 219 | baseRoute: '/api/users' 220 | })(function () {}) 221 | 222 | Controller.prototype.getUsers = () => { return 'all users' } 223 | proxy.Get()(Controller.prototype, 'getUsers', { 224 | value: Controller.prototype.getUsers 225 | }) 226 | 227 | Controller.prototype.getBlogs = () => { return 'all blogs' } 228 | proxy.Get('blogs')(Controller.prototype, 'getBlogs', { 229 | value: Controller.prototype.getBlogs 230 | }) 231 | }) 232 | 233 | afterAll(() => { 234 | register._routes = [] 235 | }) 236 | 237 | it('should return a handler for GET /api/users/', () => { 238 | const handler = resolver.resolve('GET', '/api/users/') 239 | expect(typeof handler).toEqual('object') 240 | // Handler method must be a bounded method 241 | expect(typeof handler.method.prototype).toEqual('undefined') 242 | expect(handler.method()).toEqual('all users') 243 | }) 244 | 245 | it('should return a handler for GET /api/users (without trailing slash)', () => { 246 | const handler = resolver.resolve('GET', '/api/users') 247 | expect(typeof handler).toEqual('object') 248 | // Handler method must be a bounded method 249 | expect(typeof handler.method.prototype).toEqual('undefined') 250 | expect(handler.method()).toEqual('all users') 251 | }) 252 | 253 | it('should return a handler for GET /api/users/blogs', () => { 254 | const handler = resolver.resolve('GET', '/api/users/blogs') 255 | expect(typeof handler).toEqual('object') 256 | // Handler method must be a bounded method 257 | expect(typeof handler.method.prototype).toEqual('undefined') 258 | expect(handler.method()).toEqual('all blogs') 259 | }) 260 | 261 | it('should return a handler for GET api/users/blogs (without preceding /)', () => { 262 | const handler = resolver.resolve('GET', 'api/users/blogs') 263 | expect(typeof handler).toEqual('object') 264 | // Handler method must be a bounded method 265 | expect(typeof handler.method.prototype).toEqual('undefined') 266 | expect(handler.method()).toEqual('all blogs') 267 | }) 268 | }) 269 | 270 | describe('Test Path object', () => { 271 | it('should join paths correctly', () => { 272 | expect(new Path('/', 'path', 'to', '/')._pathname).toEqual('path/to') 273 | expect(new Path('/', 'path/', '//to', '/')._pathname).toEqual('path/to') 274 | expect(new Path('/path/', '/to/', '/somewhere/')._pathname).toEqual('path/to/somewhere') 275 | expect(new Path(' /path/', '/to/ ', ' somewhere/')._pathname).toEqual('path/to/somewhere') 276 | }) 277 | 278 | it('should match routes correctly (with / at beginning or )', () => { 279 | const path = new Path('base', '/') 280 | expect(path.match('base')).toBeTruthy() 281 | expect(path.match('/base')).toBeTruthy() 282 | expect(path.match('base/')).toBeTruthy() 283 | expect(path.match('/base/')).toBeTruthy() 284 | }) 285 | }) 286 | }) 287 | -------------------------------------------------------------------------------- /src/core/__tests__/server.js: -------------------------------------------------------------------------------- 1 | const { Server } = require('../server') 2 | 3 | const createMockRequest = (method, url) => ({ 4 | method, 5 | url, 6 | on: () => 1 7 | }) 8 | 9 | const createMockResponse = () => ({ 10 | _header: {}, 11 | _status: 0, 12 | 13 | setHeader (k, v) { 14 | this._header[k] = v 15 | }, 16 | 17 | writeHead (status) { 18 | this._status = status 19 | }, 20 | 21 | end: jest.fn() 22 | }) 23 | 24 | describe('Test server', () => { 25 | describe('Test preflight requests', () => { 26 | it('should return 200 if a request with OPTIONS was sent', () => { 27 | const req = createMockRequest('OPTIONS', 'some_url') 28 | const res = createMockResponse() 29 | 30 | Server.prototype._onHttpRequest(req, res) 31 | 32 | expect(res._header).toMatchObject({ 33 | 'Access-Control-Allow-Origin': '*', 34 | 'Access-Control-Allow-Headers': '*', 35 | 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE' 36 | }) 37 | expect(res._status).toEqual(200) 38 | expect(res.end).toHaveBeenCalledTimes(1) 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/core/config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | function Parser (config) { 4 | this.injectionSyntax = /\{([a-zA-Z.]+)\}/ 5 | this.baseConfig = config 6 | } 7 | 8 | Parser.prototype._parseValues = function (config) { 9 | Object.keys(config).forEach((key) => { 10 | const value = config[key] 11 | 12 | if (typeof value === 'string') { 13 | config[key] = this._parseValueInjections(value) 14 | } else if (typeof value === 'object') { 15 | config[key] = this._parseValues(value) 16 | } 17 | }) 18 | 19 | return config 20 | } 21 | 22 | Parser.prototype.parse = function () { 23 | return this._parseValues(this.baseConfig) 24 | } 25 | 26 | /** 27 | * @param {string} p1 The key to search for. 28 | * 29 | * @return {any|undefined} Returns the value for the searched key or undefined if the key was not found. 30 | */ 31 | Parser.prototype._searchValue = function (p1) { 32 | const keys = p1.split('.') 33 | let currentValue = this.baseConfig 34 | 35 | for (let i = 0; i < keys.length && typeof currentValue !== 'undefined'; i++) { 36 | currentValue = currentValue[keys[i]] 37 | } 38 | 39 | return currentValue 40 | } 41 | 42 | Parser.prototype._parseValueInjections = function (value) { 43 | let matchResult = value.match(this.injectionSyntax) 44 | 45 | // Run as long as value contains injection syntax 46 | while (matchResult) { 47 | const resolvedValue = this._searchValue(matchResult[1]) 48 | 49 | if (resolvedValue) { 50 | if (value === matchResult[0] && typeof resolvedValue !== 'string') { 51 | value = resolvedValue 52 | break 53 | } else { 54 | value = value.replace(matchResult[0], resolvedValue) 55 | matchResult = value.match(this.injectionSyntax) 56 | } 57 | } else { 58 | break 59 | } 60 | } 61 | 62 | return value 63 | } 64 | let config 65 | 66 | exports.load = function () { 67 | config = require(path.join(process.cwd(), 'app.config.json')) 68 | config = new Parser(config).parse() 69 | } 70 | 71 | exports.get = (key) => config[key] 72 | 73 | if (process.env.NODE_ENV === 'test') { 74 | exports.Parser = Parser 75 | } 76 | -------------------------------------------------------------------------------- /src/core/controller.js: -------------------------------------------------------------------------------- 1 | const injector = require('./injector') 2 | 3 | module.exports = function Controller (properties = {}) { 4 | return function (target) { 5 | const { baseRoute, use } = properties 6 | 7 | // Base route for all functions. 8 | if (baseRoute) { 9 | target.prototype._baseRoute = baseRoute 10 | } 11 | 12 | // Dependency injection here 13 | if (use) { 14 | Object.keys(use).forEach((key) => { 15 | target.prototype[key] = injector.resolve(use[key]) 16 | }) 17 | } 18 | 19 | return target 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/core/injector.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | objects: [], 3 | 4 | resolve (Target) { 5 | let existing = this.objects.find((ref) => ref instanceof Target) 6 | if (!existing) { 7 | this.objects.push(existing = new Target()) 8 | } 9 | return existing 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/core/middleware.js: -------------------------------------------------------------------------------- 1 | const injector = require('./injector') 2 | 3 | module.exports = function Middleware (properties = {}) { 4 | return function (target) { 5 | // All middlewares must have a handle method 6 | if (typeof target.prototype.handle !== 'function') { 7 | throw new Error('Middleware should contain a handle() method') 8 | } 9 | 10 | // Dependency injection here 11 | const { use } = properties 12 | if (use) { 13 | Object.keys(use).forEach((key) => { 14 | target.prototype[key] = injector.resolve(use[key]) 15 | }) 16 | } 17 | 18 | return target 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/core/request.js: -------------------------------------------------------------------------------- 1 | const url = require('url') 2 | const jwt = require('jsonwebtoken') 3 | 4 | function Request (req, params, body) { 5 | const contentType = req.headers['content-type'] 6 | 7 | this._headers = req.headers 8 | this._query = url.parse(req.url, true).query 9 | this._params = params 10 | this._body = this._parseRequestBody(contentType, body) 11 | } 12 | 13 | Request.prototype.header = function (key = null) { 14 | if (key === null) { 15 | return this._headers 16 | } 17 | 18 | return this._headers[key.toLowerCase()] || null 19 | } 20 | 21 | Request.prototype.headerJwtDecode = function (key, jwtSecret, jwtOptions = {}) { 22 | const value = this.header(key) 23 | if (!value) { 24 | return null 25 | } 26 | 27 | try { 28 | const decoded = jwt.verify(value, jwtSecret, jwtOptions) 29 | return decoded 30 | } catch (err) { 31 | return null 32 | } 33 | } 34 | 35 | Request.prototype.authBearer = function () { 36 | const authorization = this.header('authorization') 37 | // If header does not exists. 38 | if (!authorization) { 39 | return null 40 | } 41 | 42 | // If authorization header syntax is incorrect. 43 | if (!/^bearer .+/i.test(authorization)) { 44 | return null 45 | } 46 | 47 | return authorization.split(' ')[1].trim() 48 | } 49 | 50 | Request.prototype.authBearerJwtDecode = function (jwtSecret, jwtOptions = {}) { 51 | const bearer = this.authBearer() 52 | if (!bearer) { 53 | return null 54 | } 55 | 56 | try { 57 | const decoded = jwt.verify(bearer, jwtSecret, jwtOptions) 58 | return decoded 59 | } catch (err) { 60 | return null 61 | } 62 | } 63 | 64 | Request.prototype.query = function (key = null) { 65 | if (key === null) { 66 | return this._query 67 | } 68 | 69 | return this._query[key] || null 70 | } 71 | 72 | Request.prototype.param = function (key = null) { 73 | if (key === null) { 74 | return this._params 75 | } 76 | 77 | return this._params[key] || null 78 | } 79 | 80 | Request.prototype.body = function (key = null) { 81 | if (key === null) { 82 | return this._body 83 | } 84 | 85 | return typeof this._body === 'object' 86 | ? (this._body[key] || null) 87 | : null 88 | } 89 | 90 | Request.prototype._parseRequestBody = function (contentType, body) { 91 | switch (contentType) { 92 | case 'application/json': 93 | return this._handleJsonRequest(body) 94 | 95 | default: 96 | return body 97 | } 98 | } 99 | 100 | Request.prototype._handleJsonRequest = function (body) { 101 | return JSON.parse(body) 102 | } 103 | 104 | module.exports = Request 105 | -------------------------------------------------------------------------------- /src/core/response.js: -------------------------------------------------------------------------------- 1 | function Response (res) { 2 | this.res = res 3 | } 4 | 5 | // Helper functions to send a response with a particular HTTP status code. 6 | const statuses = { 7 | success: 200, 8 | created: 201, 9 | notFound: 404, 10 | badRequest: 400, 11 | unauthorized: 401, 12 | forbidden: 403, 13 | internalServerError: 500 14 | } 15 | Object.keys(statuses).forEach((funcName) => { 16 | Response.prototype[funcName] = function (data = null, type = null) { 17 | this.send(statuses[funcName], data, type) 18 | } 19 | }) 20 | 21 | Response.prototype.header = function (key, value) { 22 | this.res.setHeader(key, value) 23 | 24 | return this 25 | } 26 | 27 | Response.prototype.send = function (status, data = null, type = null) { 28 | this.res.statusCode = status 29 | 30 | if (data) { 31 | const contentType = type || guessResponseType(data) 32 | this.header('Content-Type', contentType) 33 | 34 | this.res.write( 35 | contentType === 'application/json' 36 | ? JSON.stringify(data) 37 | : data 38 | ) 39 | } 40 | 41 | this.res.end() 42 | } 43 | 44 | function guessResponseType (data) { 45 | if (typeof data === 'object' || data instanceof Array) { 46 | return 'application/json' 47 | } 48 | return 'text/plain' 49 | } 50 | 51 | module.exports = Response 52 | -------------------------------------------------------------------------------- /src/core/route/decorator.js: -------------------------------------------------------------------------------- 1 | const register = require('./register') 2 | 3 | /** 4 | * 5 | * @param {array} params 6 | */ 7 | function parseParams (params) { 8 | let path = '' 9 | let config = {} 10 | 11 | if (params.length > 1) { 12 | // First parameter is expected to be a string and 13 | // second parameter is expected to be an object. 14 | [path, config] = params 15 | } else if (params.length === 1) { 16 | // Parameter is assumed to be a route if it is a string or a 17 | // config object is it is an object. 18 | if (typeof params[0] === 'string' || params[0] instanceof Array) { 19 | path = params[0] 20 | } else if (typeof params[0] === 'object') { 21 | config = params[0] 22 | } 23 | } 24 | 25 | return { path, config } 26 | } 27 | 28 | /** 29 | * 30 | * @param {string} method HTTP request method. One of GET, POST, PUT, PATCH or DELETE. 31 | * @param {array} args 32 | */ 33 | function getDecorator (method, ...args) { 34 | const { path, config } = parseParams(args) 35 | 36 | return function (target, name, descriptor) { 37 | if (path instanceof Array) { 38 | path.forEach(path => 39 | register.register(method, path, target, name, config) 40 | ) 41 | } else { 42 | register.register(method, path, target, name, config) 43 | } 44 | 45 | // Return custom descriptor. 46 | return { 47 | get () { 48 | return descriptor.value.bind(this) 49 | } 50 | } 51 | } 52 | } 53 | 54 | exports.getDecorator = getDecorator 55 | -------------------------------------------------------------------------------- /src/core/route/path.js: -------------------------------------------------------------------------------- 1 | const pathToRegex = require('../../helpers/pathToRegex') 2 | 3 | function Path (...paths) { 4 | paths = paths 5 | .map(path => 6 | typeof path === 'string' && this._removeTrailingSlash(path.trim()) 7 | ) 8 | 9 | this._pathname = this._removeTrailingSlash(paths.join('/')) 10 | this._regex = pathToRegex(this._pathname) 11 | } 12 | 13 | Path.prototype._removeTrailingSlash = function (path) { 14 | return path.replace(/^\/+|\/+$/g, '') 15 | } 16 | 17 | Path.prototype.match = function (pathname) { 18 | // Ensure pathname begins with a backslash before testing 19 | if (!pathname.match(/^\//)) { 20 | pathname = '/' + pathname 21 | } 22 | pathname = pathname.replace(/\/+$/, '') 23 | return this._regex.exec(pathname) 24 | } 25 | 26 | module.exports = Path 27 | -------------------------------------------------------------------------------- /src/core/route/proxies.js: -------------------------------------------------------------------------------- 1 | const decorator = require('../route/decorator') 2 | 3 | /** 4 | * Configures a route for requests with the HTTP GET method. 5 | */ 6 | exports.Get = function (...args) { 7 | return decorator.getDecorator('GET', ...args) 8 | } 9 | 10 | /** 11 | * Configures a route for requests with the HTTP POST method. 12 | */ 13 | exports.Post = function (...args) { 14 | return decorator.getDecorator('POST', ...args) 15 | } 16 | 17 | /** 18 | * Configures a route for requests with the HTTP PUT method. 19 | */ 20 | exports.Put = function (...args) { 21 | return decorator.getDecorator('PUT', ...args) 22 | } 23 | 24 | /** 25 | * Configures a route for requests with the HTTP PATCH method. 26 | */ 27 | exports.Patch = function (...args) { 28 | return decorator.getDecorator('PATCH', ...args) 29 | } 30 | 31 | /** 32 | * Configures a route for request with the HTTP DELETE method. 33 | */ 34 | exports.Delete = function (...args) { 35 | return decorator.getDecorator('DELETE', ...args) 36 | } 37 | -------------------------------------------------------------------------------- /src/core/route/register.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | _routes: [], 3 | 4 | /** 5 | * Registers a new route 6 | * 7 | * @param {string} method The HTTP method 8 | * @param {string} pathname The url path 9 | * @param {prototype} controller The prototype of the controller constructor 10 | * @param {string} methodName The name of the method 11 | * @param {object} config The configuration object. 12 | */ 13 | register: function ( 14 | method, 15 | pathname, 16 | controller, 17 | methodName, 18 | config = {} 19 | ) { 20 | const route = { 21 | method, 22 | pathname, 23 | resolveTo: { 24 | controller, 25 | methodName 26 | }, 27 | middlewares: config.middlewares || [] 28 | } 29 | this._routes.push(route) 30 | return route 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/core/route/resolver.js: -------------------------------------------------------------------------------- 1 | const register = require('./register') 2 | const Path = require('./path') 3 | 4 | /** 5 | * @param {string} method The HTTP request method 6 | * @param {string} pathname The request URL pathname 7 | * 8 | * @returns {boolean|object} 9 | */ 10 | exports.resolve = function (method, pathname) { 11 | let params = {} 12 | const route = register._routes.find(route => { 13 | const routePath = new Path( 14 | route.resolveTo.controller._baseRoute || '', 15 | route.pathname 16 | ) 17 | const result = routePath.match(pathname) 18 | if (route.method === method && result) { 19 | params = result.params 20 | return true 21 | } else { 22 | return false 23 | } 24 | }) 25 | if (route) { 26 | const { controller, methodName } = route.resolveTo 27 | return { 28 | controller: controller, 29 | method: controller[methodName], 30 | middlewares: route.middlewares, 31 | url: { 32 | params 33 | } 34 | } 35 | } 36 | 37 | return false 38 | } 39 | -------------------------------------------------------------------------------- /src/core/server.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const url = require('url') 3 | const os = require('os') 4 | const routeResolver = require('./route/resolver') 5 | const Request = require('./request') 6 | const Response = require('./response') 7 | const MiddlewareHandler = require('../helpers/middleware-runner') 8 | 9 | function Server (config) { 10 | this.config = config 11 | this.server = null 12 | } 13 | 14 | Server.prototype._onHttpRequest = function (req, res) { 15 | // Handle CORS 16 | res.setHeader('Access-Control-Allow-Origin', '*') 17 | 18 | if (req.method === 'OPTIONS') { 19 | res.setHeader('Access-Control-Allow-Headers', '*') 20 | res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE') 21 | 22 | res.writeHead(200) 23 | res.end() 24 | 25 | return 26 | } 27 | 28 | const data = [] 29 | 30 | req.on('data', (chunk) => { 31 | data.push(chunk) 32 | }) 33 | 34 | req.on('end', () => { 35 | const { pathname } = url.parse(req.url) 36 | 37 | const resolver = routeResolver.resolve(req.method, pathname) 38 | if (resolver) { 39 | const request = new Request(req, resolver.url.params, data.toString()) 40 | const response = new Response(res) 41 | 42 | // Run middlewares 43 | if (resolver.middlewares.length > 0) { 44 | new MiddlewareHandler(resolver.middlewares, request, response) 45 | .run() 46 | .then(() => { 47 | resolver.method(request, response) 48 | }) 49 | } else { 50 | resolver.method(request, response) 51 | } 52 | } else { 53 | console.log('Unable to resolve', req.method, pathname) 54 | res.writeHead(404) 55 | res.write(`Unable to resolve ${req.method} ${pathname}`) 56 | res.end() 57 | } 58 | }) 59 | } 60 | 61 | Server.prototype.start = function (callback) { 62 | this.server = http 63 | .createServer(this._onHttpRequest) 64 | .listen(this.config.port) 65 | 66 | callback({ 67 | host: os.hostname(), 68 | port: this.server.address().port 69 | }) 70 | } 71 | 72 | exports.Server = Server 73 | 74 | process.on('SIGINT', () => { 75 | process.exit() 76 | }) 77 | -------------------------------------------------------------------------------- /src/helpers/__tests__/clean-up.js: -------------------------------------------------------------------------------- 1 | const cleanUp = require('../clean-up') 2 | const file = require('../file') 3 | 4 | jest.mock('../file') 5 | 6 | describe('Test cleanUp helper', () => { 7 | afterAll(() => { 8 | cleanUp.filesToCleanUp = [] 9 | jest.restoreAllMocks() 10 | }) 11 | 12 | it('should add a dir', () => { 13 | cleanUp.addDir('some/dir') 14 | expect(cleanUp.filesToCleanUp).toEqual(['some/dir']) 15 | }) 16 | 17 | it('should add another dir', () => { 18 | cleanUp.addDir('some/other/dir') 19 | expect(cleanUp.filesToCleanUp).toEqual(['some/dir', 'some/other/dir']) 20 | }) 21 | 22 | it('should call helper method to delete dirs', () => { 23 | cleanUp.cleanUp() 24 | expect(file.deleteDir).toHaveBeenCalledTimes( 25 | cleanUp.filesToCleanUp.length 26 | ) 27 | expect(file.deleteDir.mock.calls).toEqual([ 28 | ['some/dir'], 29 | ['some/other/dir'] 30 | ]) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /src/helpers/__tests__/middleware-runner.js: -------------------------------------------------------------------------------- 1 | const MiddlewareRunner = require('../middleware-runner') 2 | 3 | const mockRequest = new function () {}() 4 | const mockResponse = new function () {}() 5 | 6 | const MiddlewareA = function () {} 7 | MiddlewareA.prototype.handle = (req, res, next) => next() 8 | 9 | const MiddlewareB = function () {} 10 | MiddlewareB.prototype.handle = (req, res, next) => next() 11 | 12 | const MiddlewareC = function () {} 13 | MiddlewareC.prototype.handle = (req, res, next) => next() 14 | 15 | const MiddlewareD = function () {} 16 | MiddlewareD.prototype.handle = (req, res, next) => next() 17 | 18 | describe('', () => { 19 | beforeAll(() => { 20 | jest.spyOn(MiddlewareA.prototype, 'handle') 21 | jest.spyOn(MiddlewareB.prototype, 'handle') 22 | jest.spyOn(MiddlewareC.prototype, 'handle') 23 | jest.spyOn(MiddlewareD.prototype, 'handle') 24 | }) 25 | 26 | it('promise should resolve', () => { 27 | return expect( 28 | new MiddlewareRunner( 29 | [MiddlewareA, MiddlewareB, MiddlewareC, MiddlewareD], 30 | mockRequest, 31 | mockResponse 32 | ).run() 33 | ).resolves.toBeUndefined() 34 | }) 35 | 36 | it('should run all middlewares', () => { 37 | const runner = new MiddlewareRunner( 38 | [MiddlewareA, MiddlewareB, MiddlewareC, MiddlewareD], 39 | mockRequest, 40 | mockResponse 41 | ) 42 | 43 | return runner 44 | .run() 45 | .then(() => { 46 | expect(MiddlewareA.prototype.handle.mock.calls[0][0]).toEqual(mockRequest) 47 | expect(MiddlewareA.prototype.handle.mock.calls[0][1]).toEqual(mockResponse) 48 | expect(typeof MiddlewareA.prototype.handle.mock.calls[0][2]).toEqual('function') 49 | 50 | expect(MiddlewareB.prototype.handle.mock.calls[0][0]).toEqual(mockRequest) 51 | expect(MiddlewareB.prototype.handle.mock.calls[0][1]).toEqual(mockResponse) 52 | expect(typeof MiddlewareB.prototype.handle.mock.calls[0][2]).toEqual('function') 53 | 54 | expect(MiddlewareC.prototype.handle.mock.calls[0][0]).toEqual(mockRequest) 55 | expect(MiddlewareC.prototype.handle.mock.calls[0][1]).toEqual(mockResponse) 56 | expect(typeof MiddlewareC.prototype.handle.mock.calls[0][2]).toEqual('function') 57 | 58 | expect(MiddlewareD.prototype.handle.mock.calls[0][0]).toEqual(mockRequest) 59 | expect(MiddlewareD.prototype.handle.mock.calls[0][1]).toEqual(mockResponse) 60 | expect(typeof MiddlewareD.prototype.handle.mock.calls[0][2]).toEqual('function') 61 | }) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /src/helpers/__tests__/pathToRegex.js: -------------------------------------------------------------------------------- 1 | const pathToRegex = require('../pathToRegex') 2 | 3 | describe('Test path to regex converter', () => { 4 | describe('Test to ensure that patterns matches pathnames correctly', () => { 5 | it('should match routes correctly (with url paramaters)', () => { 6 | const re = pathToRegex('/user/{id}') 7 | expect(re.exec('user/6')).toBeTruthy() 8 | }) 9 | 10 | it('should match /user/{id}:number correctly', () => { 11 | const re = pathToRegex('/user/{id}:number') 12 | expect(re.exec('user/6')).toBeTruthy() 13 | expect(re.exec('user/username')).toEqual(false) 14 | }) 15 | 16 | it('should throw an error if type is unknown in /user/{username}:zdidis', () => { 17 | expect(() => 18 | pathToRegex('/user/{username}:zdidis') 19 | ).toThrow() 20 | }) 21 | 22 | it('should match /user/blogs/(category)?/list correctly', () => { 23 | const re = pathToRegex('/user/blogs/(category)?/list') 24 | expect(re.exec('/user/blogs/category/list')).toBeTruthy() 25 | expect(re.exec('/user/blogs/list')).toBeTruthy() 26 | expect(re.exec('/user/blogs/list/movies')).toEqual(false) 27 | expect(re.exec('/user/blogs')).toEqual(false) 28 | }) 29 | 30 | it('should match /user/blogs/list/{category}? correctly', () => { 31 | const re = pathToRegex('/user/blogs/list/{category}?/') 32 | expect(re.exec('/user/blogs/list/movies')).toBeTruthy() 33 | expect(re.exec('/user/blogs/list/music')).toBeTruthy() 34 | expect(re.exec('/user/blogs/list/fashion')).toBeTruthy() 35 | expect(re.exec('/user/blogs/list')).toBeTruthy() 36 | expect(re.exec('/user/blogs')).toEqual(false) 37 | }) 38 | 39 | it('should match /user/{id}:number?/blogs correctly', () => { 40 | const re = pathToRegex('/user/{id}:number?/blogs') 41 | expect(re.exec('/user/27/blogs')).toBeTruthy() 42 | expect(re.exec('/user/blogs')).toBeTruthy() 43 | expect(re.exec('/user/john/blogs')).toEqual(false) 44 | }) 45 | 46 | it('should match /products/(active|inactive) correctly', () => { 47 | const re = pathToRegex('/products/(active|inactive)') 48 | expect(re.exec('/products/inactive')).toBeTruthy() 49 | expect(re.exec('/products/active')).toBeTruthy() 50 | expect(re.exec('/products')).toEqual(false) 51 | }) 52 | 53 | it('should match /products/(in)?active/list correctly', () => { 54 | const re = pathToRegex('/products/(in)?active/list') 55 | expect(re.exec('/products/inactive/list')).toBeTruthy() 56 | expect(re.exec('/products/active/list')).toBeTruthy() 57 | expect(re.exec('/products/inactive')).toEqual(false) 58 | }) 59 | 60 | it('should match /products/((in)?active)?/list correctly', () => { 61 | const re = pathToRegex('/products/((in)?active)?/list') 62 | expect(re.exec('/products/inactive/list')).toBeTruthy() 63 | expect(re.exec('/products/active/list')).toBeTruthy() 64 | expect(re.exec('/products/list')).toBeTruthy() 65 | expect(re.exec('/products/active')).toEqual(false) 66 | }) 67 | 68 | it('should match /products?/list correctly', () => { 69 | const re = pathToRegex('/products?/list') 70 | expect(re.exec('/products/list')).toBeTruthy() 71 | expect(re.exec('/list')).toBeTruthy() 72 | expect(re.exec('/products')).toEqual(false) 73 | }) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /src/helpers/__tests__/string.js: -------------------------------------------------------------------------------- 1 | const string = require('../string') 2 | 3 | describe('Test string helper functions', () => { 4 | describe('Text camelCaseToFilename()', () => { 5 | it('should convert a camelcase to a sring separated by an hyphen', () => { 6 | const testCases = { 7 | HelloNSThereF: 'hello-ns-there-f', 8 | HelloNS1ThereF: 'hello-ns1-there-f' 9 | } 10 | 11 | Object.keys(testCases).forEach(key => { 12 | expect(string.camelCaseToFilename(key)).toEqual(testCases[key]) 13 | }) 14 | }) 15 | }) 16 | 17 | describe('Test validateClassname()', () => { 18 | it('should throw an error if classname contains invalid characters', () => { 19 | const illegalNames = [ 20 | '$6789Illegal. { 25 | expect(() => 26 | string.validateClassname(name) 27 | ).toThrow('Class name can only contain letters or numbers.') 28 | }) 29 | }) 30 | 31 | it('should throw an error if classname is valid but starts with a small letter', () => { 32 | expect(() => 33 | string.validateClassname('camelCase') 34 | ).toThrow('Class name must begin with an uppercase letter') 35 | }) 36 | 37 | it('should throw an error if classname starts with a number', () => { 38 | expect(() => 39 | string.validateClassname('0CamelCase') 40 | ).toThrow('Class name must begin with an uppercase letter') 41 | }) 42 | 43 | it('should return true if classname is valid', () => { 44 | expect(string.validateClassname('CamelCase')).toEqual(true) 45 | expect(string.validateClassname('CamelCase007')).toEqual(true) 46 | }) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /src/helpers/clean-up.js: -------------------------------------------------------------------------------- 1 | const fileHelper = require('./file') 2 | 3 | module.exports = { 4 | filesToCleanUp: [], 5 | 6 | addDir (pathToClean) { 7 | this.filesToCleanUp.push(pathToClean) 8 | }, 9 | 10 | cleanUp () { 11 | this.filesToCleanUp.forEach((dir) => { 12 | fileHelper.deleteDir(dir) 13 | }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/helpers/cli.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk') 2 | const packageJson = require('./package') 3 | 4 | /** 5 | * Extract a particular value for a parameter from the command line in the format --=. 6 | * 7 | * @param {array} args An array containing a list of command line arguments. 8 | * @param {string} key The name of parameter whose value to extract. 9 | */ 10 | exports.extractParam = function (args, key) { 11 | const pattern = new RegExp(`^--${key}=`) 12 | const value = args.find((arg) => arg.match(pattern)) 13 | return value ? value.substr(key.length + 3) : null 14 | } 15 | 16 | exports.hasFlag = function (args, flag) { 17 | return args.indexOf(flag) !== -1 18 | } 19 | 20 | exports.log = function (...message) { 21 | console.log(`${chalk.gray(packageJson.name)}:`, ...message) 22 | } 23 | 24 | exports.error = function (...message) { 25 | console.error(`${chalk.gray(packageJson.name)}:`, ...message) 26 | } 27 | 28 | exports.logNewline = function (...message) { 29 | console.log(`\r\n${chalk.gray(packageJson.name)}:`, ...message) 30 | } 31 | 32 | exports.stdout = { 33 | write (message, config = {}) { 34 | message = chalk.gray(packageJson.name).concat(': ').concat(message) 35 | if (config.before) { 36 | message = config.before.concat(message) 37 | } 38 | process.stdout.write(message) 39 | } 40 | } 41 | 42 | exports.stackTrace = function (stack) { 43 | const lines = stack.split('\n') 44 | 45 | return ( 46 | lines[0].match(/^Error:/i) 47 | ? lines.splice(1) 48 | : lines 49 | ) 50 | .map(line => { 51 | if (!line.match(/^\s*at Object\.', 10 | '/path/to/3files/assets/path/fonts/img2.svg': '', 11 | '/path/to/3files/config/config.yml': '- config', 12 | '/path/to/3files/config/config.xml': '' 13 | } 14 | 15 | const results = { 16 | 'class Source1 {}': 'class Source1 -> ()', 17 | 'class Source2 {}': 'class Source2 -> ()', 18 | 'class Source3 {}': 'class Source3 -> ()' 19 | } 20 | 21 | const FakeCompiler = compiler.createCompilerClass(['js', 'jsx']) 22 | 23 | FakeCompiler.prototype.handle = function (code) { 24 | return Promise.resolve(results[code]) 25 | } 26 | 27 | describe('Test compiler base constructor', () => { 28 | let compiler 29 | let lstatSyncSpy 30 | let readFileSpy 31 | 32 | beforeAll(() => { 33 | compiler = new FakeCompiler({ minify: true }) 34 | jest.spyOn(compiler, 'handle') 35 | 36 | jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {}) 37 | jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}) 38 | 39 | readFileSpy = jest.spyOn(file, 'readString') 40 | readFileSpy.mockImplementation((filename) => sourceFiles[filename]) 41 | 42 | jest.spyOn(file, 'copyFile').mockImplementation(() => Promise.resolve()) 43 | 44 | jest.spyOn(file, 'matches') 45 | .mockImplementation((pattern, callback) => 46 | callback([ 47 | '/path/to/3files/source1.js', 48 | '/path/to/3files/sub/source2.js', 49 | '/path/to/3files/sub1/sub2/source3.js', 50 | // Some none compilable files 51 | '/path/to/3files/assets/path/img.svg', 52 | '/path/to/3files/assets/path/fonts/img.svg', 53 | '/path/to/3files/config/config.yml', 54 | '/path/to/3files/config/config.xml' 55 | ]) 56 | ) 57 | }) 58 | 59 | afterAll(() => { 60 | jest.restoreAllMocks() 61 | }) 62 | 63 | it('should set the extensions and options', () => { 64 | expect(compiler._fileExtensions).toEqual(['js', 'jsx']) 65 | expect(compiler._options).toEqual({ minify: true }) 66 | }) 67 | 68 | describe('Test _loadFiles()', () => { 69 | afterEach(() => lstatSyncSpy.mockRestore()) 70 | 71 | it('should set files to an array of one filepath is src is a filepath', () => { 72 | lstatSyncSpy = jest.spyOn(fs, 'lstatSync') 73 | lstatSyncSpy.mockImplementation(() => { 74 | return { isDirectory: () => false } 75 | }) 76 | 77 | return expect( 78 | compiler._loadFiles('/path/to/3files/source1.js') 79 | ).resolves.toEqual(['/path/to/3files/source1.js']) 80 | }) 81 | 82 | it('should try to find files using the right pattern', () => { 83 | lstatSyncSpy = jest.spyOn(fs, 'lstatSync') 84 | lstatSyncSpy.mockImplementation(() => { 85 | return { isDirectory: () => true } 86 | }) 87 | 88 | return compiler._loadFiles('/path/to/3files') 89 | .then(files => { 90 | expect(file.matches.mock.calls[0][0]).toEqual('/path/to/3files/**/*') 91 | expect(file.matches.mock.calls[0][2]).toEqual({ nodir: true }) 92 | }) 93 | }) 94 | 95 | it('should set files to an array of 7 filepaths if src is a directory', () => { 96 | lstatSyncSpy = jest.spyOn(fs, 'lstatSync') 97 | lstatSyncSpy.mockImplementation(() => { 98 | return { isDirectory: () => true } 99 | }) 100 | 101 | const fn = () => { 102 | return compiler._loadFiles('/path/to/3files') 103 | } 104 | 105 | return expect(fn()).resolves.toEqual([ 106 | '/path/to/3files/source1.js', 107 | '/path/to/3files/sub/source2.js', 108 | '/path/to/3files/sub1/sub2/source3.js', 109 | '/path/to/3files/assets/path/img.svg', 110 | '/path/to/3files/assets/path/fonts/img.svg', 111 | '/path/to/3files/config/config.yml', 112 | '/path/to/3files/config/config.xml' 113 | ]) 114 | }) 115 | }) 116 | 117 | describe('Test feature to compile a single file', () => { 118 | beforeAll(() => { 119 | jest.clearAllMocks() 120 | 121 | lstatSyncSpy = jest.spyOn(fs, 'lstatSync') 122 | lstatSyncSpy.mockImplementation(() => { 123 | return { isDirectory: () => false } 124 | }) 125 | 126 | return compiler.compile('/path/to/3files/source1.js', '/output/path/source1.js') 127 | }) 128 | 129 | afterAll(() => { 130 | lstatSyncSpy.mockRestore() 131 | jest.clearAllMocks() 132 | }) 133 | 134 | it('should set _srcType to file', () => { 135 | expect(compiler._srcType).toEqual('file') 136 | }) 137 | 138 | it('should set the _files property to an array of one filepath', () => { 139 | expect(compiler._files).toEqual(['/path/to/3files/source1.js']) 140 | }) 141 | 142 | it('should call handle() once with code to compile', () => { 143 | expect(compiler.handle).toHaveBeenCalledTimes(1) 144 | expect(compiler.handle).toHaveBeenCalledWith('class Source1 {}') 145 | }) 146 | 147 | it('should read from the right file', () => { 148 | expect(file.readString).toHaveBeenCalledTimes(1) 149 | expect(file.readString).toHaveBeenCalledWith('/path/to/3files/source1.js') 150 | }) 151 | 152 | it('should attempt to create the output directory if it doesn\'t exists', () => { 153 | expect(fs.mkdirSync).toHaveBeenCalledTimes(1) 154 | expect(fs.mkdirSync).toHaveBeenCalledWith('/output/path', { recursive: true }) 155 | }) 156 | 157 | it('should write the compiled code to the output filepath', () => { 158 | expect(fs.writeFileSync).toHaveBeenCalledTimes(1) 159 | expect(fs.writeFileSync).toHaveBeenCalledWith('/output/path/source1.js', 'class Source1 -> ()') 160 | }) 161 | }) 162 | 163 | describe('Test feature to compile a directory', () => { 164 | beforeAll(() => { 165 | lstatSyncSpy = jest.spyOn(fs, 'lstatSync') 166 | lstatSyncSpy.mockImplementation(() => { 167 | return { isDirectory: () => true } 168 | }) 169 | 170 | return compiler.compile('/path/to/3files', '/output/path') 171 | }) 172 | 173 | it('should set the _srcType property to dir', () => { 174 | expect(compiler._srcType).toEqual('dir') 175 | }) 176 | 177 | it('should set the _files property to an array', () => { 178 | expect(compiler._files).toEqual([ 179 | '/path/to/3files/source1.js', 180 | '/path/to/3files/sub/source2.js', 181 | '/path/to/3files/sub1/sub2/source3.js', 182 | '/path/to/3files/assets/path/img.svg', 183 | '/path/to/3files/assets/path/fonts/img.svg', 184 | '/path/to/3files/config/config.yml', 185 | '/path/to/3files/config/config.xml' 186 | ]) 187 | }) 188 | 189 | it('should call fs.mkdirSync to create the output dirs for the 3 files', () => { 190 | expect(fs.mkdirSync).toHaveBeenCalledTimes(7) 191 | expect(fs.mkdirSync.mock.calls).toEqual([ 192 | ['/output/path', { recursive: true }], 193 | ['/output/path/sub', { recursive: true }], 194 | ['/output/path/sub1/sub2', { recursive: true }], 195 | ['/output/path/assets/path', { recursive: true }], 196 | ['/output/path/assets/path/fonts', { recursive: true }], 197 | ['/output/path/config', { recursive: true }], 198 | ['/output/path/config', { recursive: true }] 199 | ]) 200 | }) 201 | 202 | it('should call handle() method 3 times to compile only compilable files.', () => { 203 | expect(compiler.handle).toHaveBeenCalledTimes(3) 204 | expect(compiler.handle.mock.calls).toEqual([ 205 | ['class Source1 {}'], 206 | ['class Source2 {}'], 207 | ['class Source3 {}'] 208 | ]) 209 | }) 210 | 211 | it('should read from the right filepaths', () => { 212 | expect(file.readString).toHaveBeenCalledTimes(3) 213 | expect(file.readString.mock.calls).toEqual([ 214 | ['/path/to/3files/source1.js'], 215 | ['/path/to/3files/sub/source2.js'], 216 | ['/path/to/3files/sub1/sub2/source3.js'] 217 | ]) 218 | }) 219 | 220 | it('should call fs.writeFileSync 3 times to write the outputs for the 3 files', () => { 221 | expect(fs.writeFileSync).toHaveBeenCalledTimes(3) 222 | expect(fs.writeFileSync.mock.calls).toEqual([ 223 | ['/output/path/source1.js', 'class Source1 -> ()'], 224 | ['/output/path/sub/source2.js', 'class Source2 -> ()'], 225 | ['/output/path/sub1/sub2/source3.js', 'class Source3 -> ()'] 226 | ]) 227 | }) 228 | 229 | it('shoudl copy all the non-compilable files', () => { 230 | expect(file.copyFile.mock.calls).toEqual([ 231 | ['/path/to/3files/assets/path/img.svg', '/output/path/assets/path/img.svg'], 232 | ['/path/to/3files/assets/path/fonts/img.svg', '/output/path/assets/path/fonts/img.svg'], 233 | ['/path/to/3files/config/config.yml', '/output/path/config/config.yml'], 234 | ['/path/to/3files/config/config.xml', '/output/path/config/config.xml'] 235 | ]) 236 | }) 237 | }) 238 | }) 239 | -------------------------------------------------------------------------------- /src/helpers/compilers/engines/__tests__/javascript.js: -------------------------------------------------------------------------------- 1 | const babel = require('@babel/core') 2 | const path = require('path') 3 | const Javascript = require('../javascript') 4 | 5 | const BABEL_CWD = path.join(path.dirname(__dirname), '../../../..') 6 | 7 | jest.mock('@babel/core', () => ({ 8 | transformAsync: jest.fn(() => 9 | Promise.resolve({ code: 'output-source' }) 10 | ) 11 | })) 12 | 13 | jest.mock('../../../../helpers/file', () => { 14 | return { 15 | readString: jest.fn(() => 'input-source') 16 | } 17 | }) 18 | 19 | jest.mock('fs', () => ({ 20 | lstatSync: jest.fn(() => ({ 21 | isDirectory: () => false 22 | })), 23 | mkdirSync: jest.fn(), 24 | writeFileSync: jest.fn() 25 | })) 26 | 27 | describe('Test javascript compiler', () => { 28 | let compiler 29 | 30 | beforeEach(() => { 31 | jest.clearAllMocks() 32 | }) 33 | 34 | afterAll(() => { 35 | jest.restoreAllMocks() 36 | }) 37 | 38 | it('should call babel with the correct parameters', () => { 39 | compiler = new Javascript() 40 | 41 | return compiler.compile('input-source.js', 'output-source.js') 42 | .then(() => { 43 | expect(babel.transformAsync).toHaveBeenCalledTimes(1) 44 | expect(babel.transformAsync).toHaveBeenCalledWith( 45 | 'input-source', 46 | { 47 | cwd: BABEL_CWD, 48 | configFile: false, 49 | presets: [ 50 | '@babel/preset-env' 51 | ], 52 | plugins: [ 53 | '@babel/plugin-transform-runtime', 54 | ['@babel/plugin-proposal-decorators', { legacy: true }] 55 | ] 56 | } 57 | ) 58 | }) 59 | }) 60 | 61 | it('should call babel with babel-preset-minify preset if minify option is set to true', () => { 62 | compiler = new Javascript({ minify: true }) 63 | 64 | return compiler.compile('input-source.js', 'output-source.js') 65 | .then(() => { 66 | expect(babel.transformAsync).toHaveBeenCalledTimes(1) 67 | expect(babel.transformAsync).toHaveBeenCalledWith( 68 | 'input-source', 69 | { 70 | cwd: BABEL_CWD, 71 | configFile: false, 72 | presets: [ 73 | '@babel/preset-env', 74 | 'babel-preset-minify' 75 | ], 76 | plugins: [ 77 | '@babel/plugin-transform-runtime', 78 | ['@babel/plugin-proposal-decorators', { legacy: true }] 79 | ] 80 | } 81 | ) 82 | }) 83 | }) 84 | 85 | it('promise should return what babel returns', () => { 86 | compiler = new Javascript() 87 | return expect( 88 | compiler.handle('input-source') 89 | ).resolves.toEqual('output-source') 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /src/helpers/compilers/engines/javascript.js: -------------------------------------------------------------------------------- 1 | const babel = require('@babel/core') 2 | const path = require('path') 3 | const compilerMaker = require('../index') 4 | 5 | const Javascript = compilerMaker.createCompilerClass(['js']) 6 | 7 | Javascript.prototype.handle = function (code) { 8 | const babelOptions = { 9 | cwd: path.join(__dirname, '../../../..'), 10 | configFile: false, 11 | presets: [ 12 | '@babel/preset-env' 13 | ], 14 | plugins: [ 15 | '@babel/plugin-transform-runtime', 16 | ['@babel/plugin-proposal-decorators', { legacy: true }] 17 | ] 18 | } 19 | if (typeof this._options === 'object' && this._options.minify === true) { 20 | babelOptions.presets.push('babel-preset-minify') 21 | } 22 | 23 | return babel.transformAsync(code, babelOptions).then(result => result.code) 24 | } 25 | 26 | module.exports = Javascript 27 | -------------------------------------------------------------------------------- /src/helpers/compilers/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const file = require('../file') 4 | 5 | exports.getLanguageCompiler = function (language) { 6 | const compilerPath = path.join(__dirname, 'engines', `${language}.js`) 7 | 8 | return fs.existsSync(compilerPath) ? require(compilerPath) : null 9 | } 10 | 11 | const SRC_TYPE_DIR = 'dir' 12 | const SRC_TYPE_FILE = 'file' 13 | 14 | /** 15 | * A base constructor for compilers. 16 | * 17 | * @param {string} fileExtensions Type of files to search for if compiling a directory. 18 | */ 19 | exports.createCompilerClass = function (fileExtensions) { 20 | const BaseCompiler = function (options = {}) { 21 | this._fileExtensions = fileExtensions 22 | this._promise = { resolve: null } 23 | this._options = options 24 | this._i = 0 25 | } 26 | 27 | /** 28 | * @param {string} src Filepath or directory 29 | */ 30 | BaseCompiler.prototype._loadFiles = function (src) { 31 | return new Promise((resolve, reject) => { 32 | if (fs.lstatSync(src).isDirectory()) { 33 | this._srcType = SRC_TYPE_DIR 34 | 35 | try { 36 | const dir = src.concat('/**/*') 37 | file.matches(dir, files => { 38 | resolve(files) 39 | }, { nodir: true }) 40 | } catch (e) { 41 | reject(e) 42 | } 43 | } else { 44 | this._srcType = SRC_TYPE_FILE 45 | resolve([src]) 46 | } 47 | }) 48 | } 49 | 50 | /** 51 | * Returns the filepath where the compiled output for the current file in _files[_i] will be saved. 52 | */ 53 | BaseCompiler.prototype._getOutputFilePath = function () { 54 | if (this._srcType === SRC_TYPE_FILE) { 55 | return this._dst 56 | } else if (this._srcType === SRC_TYPE_DIR) { 57 | const curSrcFilepath = this._files[this._i] 58 | // Remove the basepath (this._src). 59 | const relPath = curSrcFilepath.substr(this._src.length + 1) 60 | return path.join(this._dst, relPath) 61 | } 62 | } 63 | 64 | /** 65 | * Calls the handle() method to compile each of the files in the directory passed as src to compile() until 66 | * there are no files left. If a path to a single file was passed as src to compile(), the handle() method will 67 | * be called once to compile it. 68 | */ 69 | BaseCompiler.prototype._compileCurrentFile = function () { 70 | if (this._i < this._files.length) { 71 | const filepath = this._files[this._i] 72 | 73 | // Create output directory if it doesn't exists. 74 | const outputPath = this._getOutputFilePath() 75 | fs.mkdirSync(path.dirname(outputPath), { recursive: true }) 76 | 77 | // If file is compilable 78 | const extensionPattern = new RegExp(`\\.(${this._fileExtensions.join('|')})$`) 79 | if (extensionPattern.test(filepath)) { 80 | const code = file.readString(filepath) 81 | this.handle(code) 82 | .then(result => { 83 | fs.writeFileSync(outputPath, result) 84 | 85 | this._i++ 86 | this._compileCurrentFile() 87 | }) 88 | } else { 89 | file.copyFile(filepath, outputPath) 90 | .then(() => { 91 | this._i++ 92 | this._compileCurrentFile() 93 | }) 94 | } 95 | } else { 96 | this._promise.resolve(this._dst) 97 | } 98 | } 99 | 100 | /** 101 | * @param {string} src The path to file or directory to compile. 102 | * @param {string} dst The filepath or directory to save the result to. 103 | * 104 | * @returns {Promise} A promise to compile the file or files in directory 105 | */ 106 | BaseCompiler.prototype.compile = function (src, dst) { 107 | this._i = 0 108 | this._src = src 109 | this._dst = dst 110 | 111 | const promise = new Promise((resolve) => { 112 | this._promise.resolve = resolve 113 | }) 114 | 115 | this._loadFiles(src) 116 | .then(files => { 117 | this._files = files 118 | this._compileCurrentFile() 119 | }) 120 | 121 | return promise 122 | } 123 | 124 | return BaseCompiler 125 | } 126 | -------------------------------------------------------------------------------- /src/helpers/env.js: -------------------------------------------------------------------------------- 1 | let env 2 | switch (process.env.NODE_ENV) { 3 | case 'production': 4 | env = 'production' 5 | break 6 | case 'test': 7 | env = 'test' 8 | break 9 | case 'development': 10 | default: 11 | env = 'development' 12 | } 13 | 14 | exports.getCurrentEnvironment = function () { 15 | return env 16 | } 17 | -------------------------------------------------------------------------------- /src/helpers/file.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const glob = require('glob') 4 | const os = require('os') 5 | 6 | /** 7 | * Deletes a directory with all it's content inside recursively. 8 | * 9 | * @param {string} dir Directory to delete. 10 | */ 11 | function deleteDir (dir) { 12 | const files = fs.readdirSync(dir) 13 | 14 | files.forEach((file) => { 15 | const filePath = path.join(dir, file) 16 | 17 | if (fs.lstatSync(filePath).isDirectory()) { 18 | if (fs.readdirSync(filePath).length > 0) { 19 | deleteDir(filePath) 20 | } else { 21 | fs.rmdirSync(filePath) 22 | } 23 | } else { 24 | fs.unlinkSync(filePath) 25 | } 26 | }) 27 | 28 | fs.rmdirSync(dir) 29 | } 30 | 31 | function matches (pattern, callback, globOptions = null) { 32 | glob(pattern, globOptions, (err, matches) => { 33 | if (err) throw new Error(err) 34 | 35 | callback(matches) 36 | }) 37 | } 38 | 39 | function readString (filepath) { 40 | return fs.readFileSync(filepath, { encoding: 'utf-8' }) 41 | } 42 | 43 | /** 44 | * Creates temporary cache directory. 45 | * 46 | * @return {Promise} 47 | */ 48 | function createCacheDir (...folders) { 49 | return new Promise((resolve, reject) => { 50 | const tmpDir = path.join(os.tmpdir(), 'kasky', ...folders) 51 | 52 | fs.mkdir(tmpDir, { recursive: true }, (err) => { 53 | if (!err) { 54 | resolve(tmpDir) 55 | } else { 56 | reject(err) 57 | } 58 | }) 59 | }) 60 | } 61 | 62 | function copyFile (src, dst) { 63 | return new Promise((resolve, reject) => { 64 | fs.copyFile(src, dst, err => { 65 | if (err) reject(err) 66 | 67 | resolve(dst) 68 | }) 69 | }) 70 | } 71 | 72 | module.exports = { 73 | deleteDir, 74 | matches, 75 | readString, 76 | createCacheDir, 77 | copyFile 78 | } 79 | -------------------------------------------------------------------------------- /src/helpers/middleware-runner.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {Middleware[]} middlewares An array of middlewares to run. 3 | * @param {*} req The node server request object. 4 | * @param {*} res The node server response object. 5 | */ 6 | function MiddlewareHandler (middlewares, req, res) { 7 | this.i = 0 8 | 9 | this.middlewares = middlewares 10 | this.req = req 11 | this.res = res 12 | this._promise = { resolve: null } 13 | } 14 | 15 | MiddlewareHandler.prototype.next = function () { 16 | this.i++ 17 | if (this.i >= this.middlewares.length) { 18 | this._promise.resolve() 19 | } else { 20 | this._handle() 21 | } 22 | } 23 | 24 | MiddlewareHandler.prototype._handle = function () { 25 | if (this.i < this.middlewares.length) { 26 | new this.middlewares[this.i]() 27 | .handle( 28 | this.req, 29 | this.res, 30 | this.next.bind(this) 31 | ) 32 | } 33 | } 34 | 35 | MiddlewareHandler.prototype.run = function () { 36 | const promise = new Promise(resolve => { 37 | this._promise.resolve = resolve 38 | }) 39 | 40 | this._handle() 41 | 42 | return promise 43 | } 44 | 45 | module.exports = MiddlewareHandler 46 | -------------------------------------------------------------------------------- /src/helpers/package.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const packageJsonPath = path.join(__dirname, '..', '..', 'package.json') 4 | module.exports = require(packageJsonPath) 5 | -------------------------------------------------------------------------------- /src/helpers/pathToRegex.js: -------------------------------------------------------------------------------- 1 | const PATTERNS = [ 2 | '^\\{([a-zA-Z_-][a-zA-Z0-9_-]*)\\}(:(.+?))?\\??$', // params 3 | '(.+)\\?$' // optional 4 | ] 5 | const PARAM = 0 6 | const OPTIONAL = 1 7 | 8 | function parseParam (type, str) { 9 | if (!type) { 10 | return new RegExp('^([^\\/]+)$') 11 | } else if (type === 'number') { 12 | return new RegExp('^([0-9]+(\\.[0-9]*)?)$') 13 | } else { 14 | throw new Error('Unknown type ' + type + ' in ' + str) 15 | } 16 | } 17 | 18 | function getRegex (str) { 19 | let result 20 | if (result = str.match(PATTERNS[PARAM])) { 21 | return { 22 | name: result[1], 23 | pattern: parseParam(result[3], str), 24 | optional: Boolean(str.match(/\?$/)) 25 | } 26 | } else if (result = str.match(PATTERNS[OPTIONAL])) { 27 | return { 28 | pattern: new RegExp('^' + result[1] + '$'), 29 | optional: true 30 | } 31 | } 32 | } 33 | 34 | /** 35 | * @param {*} path 36 | * @returns {Matcher} Returns a new Matcher object 37 | */ 38 | function pathToRegex (path) { 39 | const regex = path 40 | .replace(/^\/+|\/+$/g, '') 41 | .split('/') 42 | .map(p => { 43 | if (p.match(PATTERNS.join('|'))) { 44 | return getRegex(p) 45 | } 46 | 47 | return { pattern: new RegExp('^' + p + '$'), optional: false } 48 | }) 49 | 50 | return new Matcher(regex) 51 | } 52 | 53 | module.exports = pathToRegex 54 | 55 | /** 56 | * @param {any[]} pattern 57 | */ 58 | function Matcher (pattern) { 59 | this._pattern = pattern 60 | } 61 | Matcher.prototype.exec = function (pathname) { 62 | // Removing trailing '/' 63 | pathname = pathname.replace(/^\/+|\/+$/g, '') 64 | const paths = pathname.split('/') 65 | 66 | let matches = 0 67 | 68 | const params = {} 69 | 70 | let i // counter for this._pattern 71 | let j // counter for paths 72 | let match 73 | for ( 74 | i = 0, j = 0, match; 75 | i < this._pattern.length && j < paths.length; 76 | ) { 77 | const pattern = this._pattern[i] 78 | if (match = pattern.pattern.exec(paths[j])) { 79 | if (pattern.name) { 80 | params[pattern.name] = match[1] 81 | } 82 | matches++ 83 | i++ 84 | j++ 85 | } else if (pattern.optional) { 86 | if (pattern.name) { 87 | params[pattern.name] = null 88 | } 89 | i++ 90 | } else { 91 | return false 92 | } 93 | } 94 | 95 | if (matches !== paths.length) { 96 | return false 97 | } 98 | 99 | while (i < this._pattern.length) { 100 | if (!this._pattern[i].optional) { 101 | return false 102 | } 103 | i++ 104 | } 105 | 106 | return { 107 | params 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/helpers/string.js: -------------------------------------------------------------------------------- 1 | exports.camelCaseToFilename = function (name, sep = '-') { 2 | return name 3 | .replace(/[A-Z][a-z]/g, (match) => `${sep}${match}`) 4 | .replace(/([a-z])([A-Z])/g, (match, p1, p2) => `${p1}${sep}${p2}`) 5 | .replace(new RegExp(`^${sep}|${sep}$`, 'g'), '') 6 | .toLowerCase() 7 | } 8 | 9 | /** 10 | * Validates a classname. Throws an error if the classname is not valid. 11 | */ 12 | exports.validateClassname = function (name) { 13 | if (name.match(/[^a-zA-Z0-9]/)) { 14 | throw new Error('Class name can only contain letters or numbers.') 15 | } else if (name.match(/^[a-z]/)) { 16 | throw new Error('Class name must begin with an uppercase letter') 17 | } else if (name.match(/^[0-9]/)) { 18 | throw new Error('Class name must begin with an uppercase letter') 19 | } 20 | 21 | return true 22 | } 23 | -------------------------------------------------------------------------------- /src/helpers/template.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | function replaceVars (templatePath, vars) { 4 | return templatePath.replace(/%\{([a-zA-Z]+)\}/g, (match, varName) => { 5 | const value = vars[varName] 6 | return typeof value !== 'undefined' ? value : match 7 | }) 8 | } 9 | 10 | exports.insertFile = function (templatePath, targetFilepath, values = {}) { 11 | const template = fs.readFileSync(templatePath, { encoding: 'utf8' }) 12 | 13 | fs.writeFileSync( 14 | targetFilepath, 15 | typeof values === 'object' 16 | ? replaceVars(template, values) 17 | : template, 18 | 'utf8' 19 | ) 20 | } 21 | --------------------------------------------------------------------------------