├── src ├── db │ ├── seeds │ │ ├── .gitkeep │ │ └── init_demo.js │ ├── migrations │ │ ├── .gitkeep │ │ └── 20161005172637_init_demo.js │ └── knexfile.js ├── modules │ └── .gitkeep ├── models │ ├── .eslintrc │ ├── Content.js │ └── Resource.js ├── schemas │ ├── models │ │ ├── common.yaml │ │ ├── resource.yaml │ │ └── content.yaml │ ├── common.yaml │ └── api.swagger.yaml ├── components │ ├── knex.js │ ├── config.js │ ├── redis.js │ ├── resolveAllOf.js │ └── orm.js ├── routes.js ├── config.js ├── index.js └── controllers │ ├── contents.js │ └── resources.js ├── .eslintignore ├── test ├── .eslintrc ├── common │ ├── assert.js │ └── index.js ├── routes │ ├── server.spec.js │ └── resources.inc.js └── models │ ├── Resource.spec.js │ └── Content.spec.js ├── circle.yml ├── scripts ├── .eslintrc ├── webpack.config.production.js ├── webpack.config.development.js ├── webpack.config.db.js └── webpack.config.test.js ├── index.js ├── .babelrc ├── .eslintrc ├── .gitignore ├── LICENSE ├── package.json └── README.md /src/db/seeds/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/db/migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/* 2 | dist/* 3 | coverage/* 4 | node_modules/* 5 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "import/no-extraneous-dependencies": [0], 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/models/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "global-require": [0], 4 | "class-methods-use-this": [0], 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/common/assert.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai' 2 | import chaiAsPromised from 'chai-as-promised' 3 | 4 | chai.use(chaiAsPromised) 5 | 6 | export default chai.assert 7 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.2.2 4 | test: 5 | override: 6 | - 'npm run test:junit': 7 | environment: 8 | MOCHA_FILE: $CIRCLE_TEST_REPORTS/junit/test-results.xml 9 | -------------------------------------------------------------------------------- /src/schemas/models/common.yaml: -------------------------------------------------------------------------------- 1 | BaseModel: 2 | type: object 3 | properties: 4 | createdAt: 5 | type: 'integer' 6 | format: 'int64' 7 | updatedAt: 8 | type: 'integer' 9 | format: 'int64' -------------------------------------------------------------------------------- /src/components/knex.js: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | import knexConfig from '../db/knexfile' 3 | 4 | const env = process.env.NODE_ENV || 'development' 5 | 6 | const instance = new Knex(knexConfig[env]) 7 | instance.debug(env !== 'production') 8 | 9 | export default instance 10 | -------------------------------------------------------------------------------- /src/components/config.js: -------------------------------------------------------------------------------- 1 | import { Store } from 'confidence' 2 | import rawConfig from '../config' 3 | 4 | const criteria = { env: process.env.NODE_ENV || 'development' } 5 | const store = new Store(rawConfig) 6 | 7 | export function config(key) { 8 | return store.get(key, criteria) 9 | } 10 | -------------------------------------------------------------------------------- /src/components/redis.js: -------------------------------------------------------------------------------- 1 | import redis from 'redis' 2 | import { Promise } from 'bluebird' 3 | 4 | import { config } from './config' 5 | 6 | Promise.promisifyAll(redis.RedisClient.prototype) 7 | Promise.promisifyAll(redis.Multi.prototype) 8 | 9 | export default redis.createClient(config('/modules/redis/client')) 10 | -------------------------------------------------------------------------------- /scripts/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-airbnb/legacy", 3 | "rules": { 4 | "import/no-extraneous-dependencies": [0], 5 | "max-len": [1, 120, 2, {ignoreComments: true}], 6 | "no-else-return": [0], 7 | "semi": [2, "never"], 8 | "no-console": [0], 9 | "arrow-body-style": [0], 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/models/Content.js: -------------------------------------------------------------------------------- 1 | import Model from '../components/orm' 2 | import { get as ContentSchema } from '../schemas/models/content.yaml' 3 | 4 | export default class Content extends Model { 5 | 6 | static schema = ContentSchema; 7 | 8 | get tableName() { 9 | return 'Content' 10 | } 11 | 12 | resource() { 13 | return this.belongsTo('Resource', 'resourceId') 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* eslint global-require: [0] no-console: [0] no-var: [0] import/no-unresolved: [0] */ 2 | 3 | var app 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | app = require('./dist/server') 7 | } else { 8 | app = require('./build/src') 9 | } 10 | 11 | Promise.resolve(app.main()).then(() => { 12 | console.log('Application is running.') 13 | }).catch((e) => { 14 | console.error(e.stack) 15 | }) 16 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "sourceMap": true, 3 | "presets": [ 4 | "stage-2", 5 | ["env", { 6 | "targets": { 7 | "node": 6, 8 | }, 9 | "debug": false, 10 | }] 11 | ], 12 | "plugins": [ 13 | "add-module-exports", 14 | "lodash", 15 | "source-map-support-for-6", 16 | ], 17 | "env": { 18 | "test": { 19 | "plugins": [ 20 | "istanbul", 21 | ], 22 | }, 23 | "production": { 24 | "presets": [ 25 | "babili", 26 | ], 27 | }, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/routes/server.spec.js: -------------------------------------------------------------------------------- 1 | import { Server } from 'hapi' 2 | import { main } from '../../src' 3 | import knex from '../../src/components/knex' 4 | 5 | import { setup, teardown } from '../common' 6 | 7 | before(() => setup(knex)) 8 | 9 | after(() => teardown(knex)) 10 | 11 | const server = new Server({ debug: false }) 12 | 13 | before(async () => main(server)) 14 | 15 | describe('Test server routes', () => { 16 | const childContext = require.context('./', false, /\.inc\.js$/) 17 | 18 | childContext.keys().forEach(key => childContext(key)(server)) 19 | }) 20 | -------------------------------------------------------------------------------- /src/models/Resource.js: -------------------------------------------------------------------------------- 1 | import uuid from 'uuid' 2 | 3 | import Model from '../components/orm' 4 | import { get as ResourceSchema } from '../schemas/models/resource.yaml' 5 | 6 | export default class Resource extends Model { 7 | 8 | static schema = ResourceSchema; 9 | 10 | constructor(...args) { 11 | super(...args) 12 | 13 | this.on('creating', () => { 14 | this.set('id', uuid.v4()) 15 | }) 16 | } 17 | 18 | get tableName() { 19 | return 'Resource' 20 | } 21 | 22 | contents() { 23 | return this.hasMany('Content', 'resourceId') 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import * as resources from './controllers/resources' 2 | import * as contents from './controllers/contents' 3 | 4 | export default { 5 | 6 | findResource: resources.find, 7 | 8 | createResource: resources.create, 9 | 10 | getResource: resources.get, 11 | 12 | updateResource: resources.update, 13 | 14 | deleteResource: resources.destroy, 15 | 16 | getResourceUrl: resources.getUrl, 17 | 18 | findContent: contents.find, 19 | 20 | createContent: contents.create, 21 | 22 | getContent: contents.get, 23 | 24 | updateContent: contents.update, 25 | 26 | deleteContent: contents.destroy, 27 | 28 | } 29 | -------------------------------------------------------------------------------- /test/common/index.js: -------------------------------------------------------------------------------- 1 | import { Promise } from 'bluebird' 2 | import { reverse } from 'lodash/fp' 3 | 4 | import knex from '../../src/components/knex' 5 | 6 | export async function setup() { 7 | const requireDbMigrations = require.context('../../src/db/migrations', false, /\.js$/) 8 | 9 | await Promise.each(requireDbMigrations.keys(), key => requireDbMigrations(key).up(knex, Promise)) 10 | } 11 | 12 | export async function teardown() { 13 | const requireDbMigrations = require.context('../../src/db/migrations', false, /\.js$/) 14 | 15 | await Promise.each(reverse(requireDbMigrations.keys()), key => requireDbMigrations(key).down(knex, Promise)) 16 | } 17 | -------------------------------------------------------------------------------- /src/schemas/common.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | LimitQuery: 3 | name: limit 4 | in: query 5 | description: max number of entries to return 6 | type: integer 7 | default: 20 8 | minimum: 1 9 | maximum: 50 10 | OffsetQuery: 11 | name: offset 12 | in: query 13 | description: number of entries to skip 14 | type: integer 15 | default: 0 16 | minimum: 0 17 | OrderByQuery: 18 | name: orderBy 19 | in: query 20 | description: name of field to order by 21 | type: string 22 | default: 'id' 23 | OrderDirectionQuery: 24 | name: orderDirection 25 | in: query 26 | description: direction of order (asc or desc) 27 | type: string 28 | enum: 29 | - asc 30 | - desc 31 | default: asc -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-airbnb", 3 | "ecmaFeatures": { 4 | globalReturn: true 5 | }, 6 | "parser": "babel-eslint", 7 | "rules": { 8 | "no-console": [1], 9 | "max-len": [1, 120, 2, {ignoreComments: true}], 10 | "no-else-return": [0], 11 | "semi": [2, "never"], 12 | "import/prefer-default-export": [0], 13 | "arrow-body-style": [0], 14 | }, 15 | "globals": { 16 | "__CLIENT__": true, 17 | "__SERVER__": true, 18 | "__PRODUCTION__": true, 19 | "__DEV__": true, 20 | "document": false, 21 | "escape": false, 22 | "navigator": false, 23 | "unescape": false, 24 | "window": false, 25 | "describe": true, 26 | "before": true, 27 | "after": true, 28 | "it": true, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/db/knexfile.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | development: { 4 | client: 'mysql', 5 | connection: { 6 | host: '127.0.0.1', 7 | port: 3306, 8 | user: 'demo', 9 | password: 'demo', 10 | database: 'demo', 11 | }, 12 | useNullAsDefault: true, 13 | }, 14 | 15 | production: { 16 | client: 'mysql', 17 | connection: { 18 | host: process.env.MYSQL_HOST, 19 | port: process.env.MYSQL_PORT, 20 | user: process.env.MYSQL_USER, 21 | password: process.env.MYSQL_PASSWORD, 22 | database: process.env.MYSQL_DATABASE, 23 | }, 24 | }, 25 | 26 | test: { 27 | client: 'sqlite3', 28 | connection: { 29 | filename: ':memory:', 30 | }, 31 | useNullAsDefault: true, 32 | }, 33 | 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # User generated content 40 | build 41 | dist 42 | test-results.xml 43 | -------------------------------------------------------------------------------- /src/schemas/models/resource.yaml: -------------------------------------------------------------------------------- 1 | update: 2 | type: object 3 | properties: 4 | name: 5 | type: string 6 | minLength: 1 7 | maxLength: 32 8 | status: 9 | type: integer 10 | enum: 11 | - 0 12 | - 1 13 | - 2 14 | create: 15 | type: object 16 | allOf: 17 | - $ref: 'resource.yaml#/update' 18 | - properties: 19 | status: 20 | default: 0 21 | required: 22 | - name 23 | get: 24 | type: object 25 | allOf: 26 | - $ref: 'common.yaml#/BaseModel' 27 | - $ref: 'resource.yaml#/create' 28 | - properties: 29 | id: 30 | type: string 31 | format: uuid 32 | md5: 33 | type: string 34 | minLength: 32 35 | maxLength: 32 36 | mime: 37 | type: string 38 | minLength: 1 39 | maxLength: 32 40 | -------------------------------------------------------------------------------- /src/components/resolveAllOf.js: -------------------------------------------------------------------------------- 1 | import { isObjectLike, isArray, mergeWith, union, omit, mapValues, map } from 'lodash/fp' 2 | 3 | function customizer(objValue, srcValue) { 4 | if (isArray(objValue)) { 5 | return union(objValue, srcValue) 6 | } else { 7 | return undefined 8 | } 9 | } 10 | 11 | export default function resolveAllOf(inputSpec) { 12 | if (isObjectLike(inputSpec)) { 13 | const { allOf } = inputSpec 14 | 15 | if (isArray(allOf)) { 16 | return allOf.reduce( 17 | (prevValue, value) => mergeWith(customizer, prevValue, resolveAllOf(value)), 18 | omit('allOf', inputSpec), 19 | ) 20 | } else if (isArray(inputSpec)) { 21 | return map(value => resolveAllOf(value), inputSpec) 22 | } else { 23 | return mapValues(value => resolveAllOf(value), inputSpec) 24 | } 25 | } else { 26 | return inputSpec 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /scripts/webpack.config.production.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpackNodeExternals = require('webpack-node-externals') 3 | const packageConfig = require('../package.json') 4 | 5 | module.exports = { 6 | devtool: 'source-map', 7 | target: 'node', 8 | module: { 9 | loaders: [ 10 | { 11 | test: /(\.jsx|\.js)$/, 12 | loader: 'babel', 13 | exclude: /(node_modules|bower_components)/ 14 | }, 15 | { 16 | test: /(\.json|\.yml|\.yaml)$/, 17 | loader: 'json-schema', 18 | exclude: /(node_modules|bower_components)/ 19 | } 20 | ] 21 | }, 22 | resolve: { 23 | root: path.resolve('.'), 24 | extensions: ['', '.js', '.json'] 25 | }, 26 | entry: 'src/index.js', 27 | output: { 28 | path: `${__dirname}/../dist`, 29 | filename: 'server.js', 30 | library: packageConfig.name, 31 | libraryTarget: 'umd' 32 | }, 33 | externals: [ 34 | webpackNodeExternals() 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/schemas/models/content.yaml: -------------------------------------------------------------------------------- 1 | update: 2 | type: object 3 | properties: 4 | resourceId: 5 | type: string 6 | format: uuid 7 | name: 8 | type: string 9 | minLength: 1 10 | maxLength: 32 11 | title: 12 | type: string 13 | minLength: 1 14 | maxLength: 255 15 | description: 16 | type: string 17 | data: 18 | type: object 19 | additionalProperties: true 20 | status: 21 | type: integer 22 | enum: 23 | - 0 24 | - 1 25 | - 2 26 | additionalProperties: false 27 | create: 28 | type: object 29 | allOf: 30 | - $ref: 'content.yaml#/update' 31 | - properties: 32 | status: 33 | default: 0 34 | required: 35 | - name 36 | - resourceId 37 | - data 38 | get: 39 | type: object 40 | allOf: 41 | - $ref: 'common.yaml#/BaseModel' 42 | - $ref: 'content.yaml#/create' 43 | - properties: 44 | id: 45 | type: integer 46 | format: int32 47 | -------------------------------------------------------------------------------- /src/db/migrations/20161005172637_init_demo.js: -------------------------------------------------------------------------------- 1 | export async function up(knex) { 2 | await knex.schema.createTable('Resource', (table) => { 3 | table.uuid('id').notNullable().primary() 4 | table.string('name', 32).notNullable() 5 | table.string('mime', 32) 6 | table.string('md5') 7 | table.integer('status').notNullable() 8 | table.bigInteger('createdAt') 9 | table.bigInteger('updatedAt') 10 | }) 11 | 12 | await knex.schema.createTable('Content', (table) => { 13 | table.increments() 14 | table.uuid('resourceId').notNullable().references('Resource.id') 15 | table.string('name', 32).notNullable() 16 | table.string('title', 255) 17 | table.text('description') 18 | table.json('data') 19 | table.integer('status').notNullable() 20 | table.bigInteger('createdAt') 21 | table.bigInteger('updatedAt') 22 | }) 23 | } 24 | 25 | export async function down(knex) { 26 | await knex.schema.dropTableIfExists('Content') 27 | await knex.schema.dropTableIfExists('Resource') 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Yoshi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /scripts/webpack.config.development.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const webpackNodeExternals = require('webpack-node-externals') 4 | const packageConfig = require('../package.json') 5 | 6 | module.exports = { 7 | devtool: 'source-map', 8 | target: 'node', 9 | module: { 10 | loaders: [ 11 | { 12 | test: /(\.jsx|\.js)$/, 13 | loader: 'babel', 14 | exclude: /(node_modules|bower_components)/ 15 | }, 16 | { 17 | test: /(\.json|\.yml|\.yaml)$/, 18 | loader: 'json-schema', 19 | exclude: /(node_modules|bower_components)/ 20 | } 21 | ] 22 | }, 23 | resolve: { 24 | root: path.resolve('.'), 25 | extensions: ['', '.js', '.json'] 26 | }, 27 | entry: 'src/index.js', 28 | output: { 29 | path: `${__dirname}/../build`, 30 | filename: 'src/index.js', 31 | library: packageConfig.name, 32 | libraryTarget: 'umd', 33 | devtoolModuleFilenameTemplate: '../../[resource-path]', 34 | devtoolFallbackModuleFilenameTemplate: '../../[resource-path]' 35 | }, 36 | externals: [ 37 | webpackNodeExternals() 38 | ], 39 | plugins: [ 40 | new webpack.ProvidePlugin({ 41 | Promise: 'bluebird' 42 | }) 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 3 | env: process.env.NODE_ENV || 'development', 4 | 5 | server: { 6 | 7 | port: process.env.PORT || 8080, 8 | 9 | host: process.env.HOSTNAME || '0.0.0.0', 10 | 11 | listen: { 12 | 13 | $filter: 'env', 14 | 15 | test: false, 16 | 17 | $default: true, 18 | 19 | }, 20 | 21 | publicUrl: { 22 | 23 | $filter: 'env', 24 | 25 | production: process.env.PUBLIC_URL, 26 | 27 | $default: process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 8080}`, 28 | 29 | }, 30 | 31 | }, 32 | 33 | modules: { 34 | 35 | good: { 36 | 37 | $filter: 'env', 38 | 39 | test: false, 40 | 41 | $default: true, 42 | 43 | }, 44 | 45 | redis: { 46 | 47 | namespace: { 48 | 49 | $filter: 'env', 50 | 51 | test: 'crevid::demo::test', 52 | 53 | development: 'crevid::demo::dev', 54 | 55 | $default: 'crevid::demo', 56 | 57 | }, 58 | 59 | client: { 60 | 61 | $filter: 'env', 62 | 63 | production: { 64 | 65 | host: process.env.REDIS_HOST, 66 | 67 | port: process.env.REDIS_PORT, 68 | 69 | }, 70 | 71 | $default: { 72 | 73 | host: '127.0.0.1', 74 | 75 | port: '6379', 76 | 77 | }, 78 | 79 | }, 80 | 81 | }, 82 | 83 | swaggerUi: { 84 | 85 | $filter: 'env', 86 | 87 | test: false, 88 | 89 | $default: { 90 | 91 | path: '/docs', 92 | 93 | }, 94 | 95 | }, 96 | 97 | }, 98 | 99 | } 100 | -------------------------------------------------------------------------------- /test/models/Resource.spec.js: -------------------------------------------------------------------------------- 1 | import { omit, isMatch } from 'lodash/fp' 2 | import assert from '../common/assert' 3 | import { setup, teardown } from '../common' 4 | import Resource from '../../src/models/Resource' 5 | 6 | const uuidPattern = /^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/ 7 | 8 | describe('Test Resource model', () => { 9 | before(() => setup()) 10 | 11 | after(() => teardown()) 12 | 13 | describe('Create Resource', () => { 14 | const validModel = { 15 | name: 'input.mp4', 16 | md5: '2b4c9f4a2d219937a1f7302c593ff025', 17 | status: 2, 18 | } 19 | 20 | it('Should reject if missing \'name\'', () => 21 | assert.isRejected(Resource.forge(omit('name')(validModel)).save()) 22 | ) 23 | 24 | it('Should success if missing \'md5\'', async () => { 25 | const task = await Resource.forge(omit('md5')(validModel)).save() 26 | assert(isMatch(omit('md5', validModel), task.toJSON())) 27 | }) 28 | 29 | it('Should reject if md5 is an invalid string', () => 30 | assert.isRejected(Resource.forge({ 31 | ...validModel, 32 | md5: 'Invalid', 33 | }).save()) 34 | ) 35 | 36 | it('Should success if submit valid data and generate id as an uuid string', async () => { 37 | const task = await Resource.forge(validModel).save() 38 | assert(isMatch(validModel, task.toJSON())) 39 | assert(uuidPattern.test(task.id)) 40 | }) 41 | 42 | it('Should reject if include extra data', async () => { 43 | await assert.isRejected(Resource.forge({ 44 | ...validModel, 45 | foo: 'bar', 46 | }).save()) 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { Server } from 'hapi' 2 | import good from 'good' 3 | import inert from 'inert' 4 | import vision from 'vision' 5 | import hapiSwaggeredUi from 'hapi-swaggered-ui' 6 | import * as overjoyAwait from 'overjoy-await' 7 | import * as overjoySwag from 'overjoy-swag' 8 | 9 | import { config } from './components/config' 10 | import routes from './routes' 11 | import api from './schemas/api.swagger.yaml' 12 | 13 | export async function main(server = new Server()) { 14 | server.connection({ 15 | port: config('/server/port'), 16 | host: config('/server/host'), 17 | router: { 18 | stripTrailingSlash: true, 19 | }, 20 | }) 21 | 22 | // don't log when test 23 | if (config('/modules/good')) { 24 | await server.register({ 25 | register: good, 26 | options: { 27 | reporters: { 28 | console: [{ 29 | module: 'good-console', 30 | }, 'stdout'], 31 | }, 32 | }, 33 | }) 34 | } 35 | 36 | await server.register(overjoyAwait) 37 | 38 | await server.register(inert) 39 | await server.register(vision) 40 | 41 | await server.register({ 42 | register: overjoySwag, 43 | options: { 44 | schema: api, 45 | handlers: routes, 46 | handlerTransform: 'await', 47 | }, 48 | }) 49 | 50 | const swaggerUiConfig = config('/modules/swaggerUi') 51 | if (swaggerUiConfig) { 52 | await server.register({ 53 | register: hapiSwaggeredUi, 54 | options: { 55 | path: swaggerUiConfig.path, 56 | swaggerEndpoint: '/swagger.json', 57 | }, 58 | }) 59 | } 60 | 61 | // don't start server on test 62 | if (config('/server/listen')) { 63 | await server.start() 64 | server.log('server', `Server is listening at: ${server.info.uri.toLowerCase()}`) 65 | } 66 | 67 | return server 68 | } 69 | -------------------------------------------------------------------------------- /src/controllers/contents.js: -------------------------------------------------------------------------------- 1 | import Boom from 'boom' 2 | 3 | import Content from '../models/Content' 4 | 5 | export async function find(req, reply) { 6 | const total = await Content.count() 7 | 8 | const { limit, offset, orderBy, orderDirection } = req.query 9 | 10 | const contents = await Content.collection().query({ 11 | limit, 12 | offset, 13 | orderBy: [orderBy, orderDirection], 14 | }).fetch() 15 | 16 | return reply(contents.toJSON()) 17 | .header('X-Meta-Total', total) 18 | } 19 | 20 | export async function create(req) { 21 | const content = await Content.forge(req.payload).save() 22 | 23 | return content.toJSON() 24 | } 25 | 26 | export async function get(req) { 27 | const { id } = req.params 28 | 29 | const content = await Content.forge({ id }).fetch() 30 | 31 | if (!content) { 32 | throw Boom.notFound('Content not found') 33 | } 34 | 35 | return content.toJSON() 36 | } 37 | 38 | export async function update(req) { 39 | const { id } = req.params 40 | 41 | const content = await Content.forge({ id }).fetch() 42 | 43 | if (!content) { 44 | throw Boom.notFound('Content not found') 45 | } 46 | 47 | content.set(req.payload) 48 | 49 | try { 50 | return (await content.save()).toJSON() 51 | } catch (e) { 52 | if (e instanceof Content.NoRowsUpdatedError) { 53 | throw Boom.notFound('Content not found') 54 | } else { 55 | throw e 56 | } 57 | } 58 | } 59 | 60 | export async function destroy(req, reply) { 61 | const { id } = req.params 62 | 63 | try { 64 | await Content.forge({ id }).destroy({ require: true }) 65 | 66 | return reply().code(204) 67 | } catch (error) { 68 | if (error instanceof Content.NoRowsDeletedError) { 69 | throw Boom.notFound('Content not found') 70 | } else { 71 | throw error 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/controllers/resources.js: -------------------------------------------------------------------------------- 1 | import Boom from 'boom' 2 | 3 | import Resource from '../models/Resource' 4 | 5 | export async function find(req, reply) { 6 | const total = await Resource.count() 7 | 8 | const { limit, offset, orderBy, orderDirection } = req.query 9 | 10 | const resources = await Resource.collection().query({ 11 | limit, 12 | offset, 13 | orderBy: [orderBy, orderDirection], 14 | }).fetch() 15 | 16 | return reply(resources.toJSON()) 17 | .header('X-Meta-Total', total) 18 | } 19 | 20 | export async function create(req) { 21 | const resource = await Resource.forge(req.payload).save() 22 | 23 | return resource.toJSON() 24 | } 25 | 26 | export async function get(req) { 27 | const { id } = req.params 28 | 29 | const resource = await Resource.forge({ id }).fetch() 30 | 31 | if (!resource) { 32 | throw Boom.notFound('Resource not found') 33 | } 34 | 35 | return resource.toJSON() 36 | } 37 | 38 | export async function update(req) { 39 | const { id } = req.params 40 | 41 | const resource = await Resource.forge({ id }).fetch() 42 | 43 | if (!resource) { 44 | throw Boom.notFound('Resource not found') 45 | } 46 | 47 | resource.set(req.payload) 48 | 49 | try { 50 | return (await resource.save()).toJSON() 51 | } catch (e) { 52 | if (e instanceof Resource.NoRowsUpdatedError) { 53 | throw Boom.notFound('Resource not found') 54 | } else { 55 | throw e 56 | } 57 | } 58 | } 59 | 60 | export async function destroy(req, reply) { 61 | const { id } = req.params 62 | 63 | try { 64 | await Resource.forge({ id }).destroy({ require: true }) 65 | 66 | return reply().code(204) 67 | } catch (error) { 68 | if (error instanceof Resource.NoRowsDeletedError) { 69 | throw Boom.notFound('Resource not found') 70 | } else { 71 | throw error 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /scripts/webpack.config.db.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const webpackNodeExternals = require('webpack-node-externals') 4 | const packageConfig = require('../package.json') 5 | 6 | function flatMap(array, mapper) { 7 | return array.map(mapper).reduce((value, entry) => value.concat(entry), []) 8 | } 9 | 10 | function walkSync(dir, criteria) { 11 | const entries = fs.readdirSync(dir) 12 | 13 | return flatMap(entries, (entry) => { 14 | const absolutePath = [dir, entry].join('/') 15 | 16 | if (fs.statSync(absolutePath).isDirectory()) { 17 | return walkSync(absolutePath, criteria) 18 | } else if (criteria) { 19 | return criteria(absolutePath) ? absolutePath : [] 20 | } else { 21 | return absolutePath 22 | } 23 | }) 24 | } 25 | 26 | const entries = walkSync('src/db', entry => entry.match(/\.js$/)) 27 | .reduce((prev, entry) => (Object.assign({}, prev, { [entry.replace('src/', '')]: [entry] })), {}) 28 | 29 | module.exports = { 30 | devtool: 'source-map', 31 | target: 'node', 32 | module: { 33 | loaders: [ 34 | { 35 | test: /(\.jsx|\.js)$/, 36 | loader: 'babel', 37 | exclude: /(node_modules|bower_components)/ 38 | }, 39 | { 40 | test: /(\.json|\.yml|\.yaml)$/, 41 | loader: 'json-schema', 42 | exclude: /(node_modules|bower_components)/ 43 | } 44 | ] 45 | }, 46 | resolve: { 47 | root: path.resolve('.'), 48 | extensions: ['', '.js', '.json'] 49 | }, 50 | entry: entries, 51 | output: { 52 | path: `${__dirname}/../dist`, 53 | filename: '[name]', 54 | library: packageConfig.name, 55 | libraryTarget: 'umd', 56 | devtoolModuleFilenameTemplate: '../../../[resource-path]', 57 | devtoolFallbackModuleFilenameTemplate: '../../../[resource-path]' 58 | }, 59 | externals: [ 60 | webpackNodeExternals() 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /test/models/Content.spec.js: -------------------------------------------------------------------------------- 1 | import { omit, isMatch } from 'lodash/fp' 2 | 3 | import assert from '../common/assert' 4 | import { setup, teardown } from '../common' 5 | import Content from '../../src/models/Content' 6 | import Resource from '../../src/models/Resource' 7 | 8 | describe('Test Content model', () => { 9 | before(() => setup()) 10 | 11 | after(() => teardown()) 12 | 13 | describe('Create Content', () => { 14 | const validModel = { 15 | name: 'input.mp4', 16 | title: 'Input file for test', 17 | description: 'Lorem ipsum', 18 | data: { 19 | mime: 'video/mpeg4', 20 | }, 21 | status: 2, 22 | } 23 | 24 | before(async () => { 25 | const resource = await Resource.forge({ 26 | name: 'input.mp4', 27 | md5: '2b4c9f4a2d219937a1f7302c593ff025', 28 | status: 2, 29 | }).save() 30 | 31 | validModel.resourceId = resource.id 32 | }) 33 | 34 | it('Should reject if missing \'name\'', () => 35 | assert.isRejected(Content.forge(omit('name', validModel)).save()) 36 | ) 37 | 38 | it('Should reject if missing \'resourceId\'', () => 39 | assert.isRejected(Content.forge(omit('resourceId', validModel)).save()) 40 | ) 41 | 42 | it('Should reject if missing \'data\'', () => 43 | assert.isRejected(Content.forge(omit('data', validModel)).save()) 44 | ) 45 | 46 | // it('Should reject if submit invalid \'resourceId\'', () => 47 | // assert.isRejected(Content.forge({ 48 | // ...validModel, 49 | // resourceId: uuid.v4(), 50 | // }).save()) 51 | // ) 52 | 53 | it('Should success if submit valid data', async () => { 54 | const task = await Content.forge(validModel).save() 55 | assert(isMatch(validModel, task.toJSON())) 56 | }) 57 | 58 | it('Should reject if include extra data', async () => { 59 | await assert.isRejected(Content.forge({ 60 | ...validModel, 61 | foo: 'bar', 62 | }).save()) 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /scripts/webpack.config.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const webpack = require('webpack') 4 | const webpackNodeExternals = require('webpack-node-externals') 5 | const packageConfig = require('../package.json') 6 | 7 | function flatMap(array, mapper) { 8 | return array.map(mapper).reduce((value, entry) => value.concat(entry), []) 9 | } 10 | 11 | function walkSync(dir, criteria) { 12 | const entries = fs.readdirSync(dir) 13 | 14 | return flatMap(entries, (entry) => { 15 | const absolutePath = [dir, entry].join('/') 16 | 17 | if (fs.statSync(absolutePath).isDirectory()) { 18 | return walkSync(absolutePath, criteria) 19 | } else if (criteria) { 20 | return criteria(absolutePath) ? absolutePath : [] 21 | } else { 22 | return absolutePath 23 | } 24 | }) 25 | } 26 | 27 | const entries = walkSync('test', entry => entry.match(/spec\.js$/)) 28 | .reduce((prev, entry) => (Object.assign({}, prev, { [entry]: [entry] })), {}) 29 | 30 | module.exports = { 31 | devtool: 'source-map', 32 | target: 'node', 33 | module: { 34 | loaders: [ 35 | { 36 | test: /(\.jsx|\.js)$/, 37 | loader: 'babel', 38 | exclude: /(node_modules|bower_components)/ 39 | }, 40 | { 41 | test: /(\.json|\.yml|\.yaml)$/, 42 | loader: 'json-schema', 43 | exclude: /(node_modules|bower_components)/ 44 | } 45 | ] 46 | }, 47 | resolve: { 48 | root: path.resolve('.'), 49 | extensions: ['', '.js', '.json'] 50 | }, 51 | entry: entries, 52 | output: { 53 | path: `${__dirname}/../build`, 54 | filename: '[name]', 55 | library: packageConfig.name, 56 | libraryTarget: 'umd', 57 | devtoolModuleFilenameTemplate: '../../../[resource-path]', 58 | devtoolFallbackModuleFilenameTemplate: '../../../[resource-path]' 59 | }, 60 | externals: [ 61 | webpackNodeExternals() 62 | ], 63 | plugins: [ 64 | new webpack.ProvidePlugin({ 65 | Promise: 'bluebird' 66 | }) 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /src/db/seeds/init_demo.js: -------------------------------------------------------------------------------- 1 | import uuid from 'uuid' 2 | 3 | export async function seed(knex) { 4 | await knex('Resource').truncate() 5 | const resourceId1 = uuid.v4() 6 | await knex('Resource').insert({ 7 | id: resourceId1, 8 | name: 'Demo Resource #1', 9 | mime: 'application/octet-stream', 10 | md5: '14994fe4b40716b7029045a559ef8922', 11 | status: 0, 12 | createdAt: Date.now(), 13 | updatedAt: Date.now(), 14 | }) 15 | const resourceId2 = uuid.v4() 16 | await knex('Resource').insert({ 17 | id: resourceId2, 18 | name: 'Demo Resource #2', 19 | mime: 'application/octet-stream', 20 | md5: 'f58016895b18052d00e0ae82359a570b', 21 | status: 0, 22 | createdAt: Date.now(), 23 | updatedAt: Date.now(), 24 | }, 'id') 25 | const resourceId3 = uuid.v4() 26 | await knex('Resource').insert({ 27 | id: resourceId3, 28 | name: 'Demo Resource #3', 29 | mime: 'application/octet-stream', 30 | md5: 'cc9f2c34a503775c8803ca42bd725e26', 31 | status: 0, 32 | createdAt: Date.now(), 33 | updatedAt: Date.now(), 34 | }, 'id') 35 | 36 | await knex('Content').truncate() 37 | await knex('Content').insert({ 38 | resourceId: resourceId1, 39 | name: 'Demo Content #1', 40 | title: 'Donec nec placerat purus', 41 | description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean egestas id quam eget rutrum.', 42 | data: JSON.stringify({ 43 | foo: 'bar', 44 | }), 45 | status: 0, 46 | createdAt: Date.now(), 47 | updatedAt: Date.now(), 48 | }) 49 | await knex('Content').insert({ 50 | resourceId: resourceId1, 51 | name: 'Demo Content #2', 52 | title: 'Donec nec placerat purus', 53 | description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean egestas id quam eget rutrum.', 54 | data: JSON.stringify({ 55 | foo: 'bar', 56 | }), 57 | status: 0, 58 | createdAt: Date.now(), 59 | updatedAt: Date.now(), 60 | }) 61 | await knex('Content').insert({ 62 | resourceId: resourceId2, 63 | name: 'Demo Content #3', 64 | title: 'Donec nec placerat purus', 65 | description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean egestas id quam eget rutrum.', 66 | data: JSON.stringify({ 67 | foo: 'bar', 68 | }), 69 | status: 0, 70 | createdAt: Date.now(), 71 | updatedAt: Date.now(), 72 | }) 73 | await knex('Content').insert({ 74 | resourceId: resourceId2, 75 | name: 'Demo Content #4', 76 | title: 'Donec nec placerat purus', 77 | description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean egestas id quam eget rutrum.', 78 | data: JSON.stringify({ 79 | foo: 'bar', 80 | }), 81 | status: 0, 82 | createdAt: Date.now(), 83 | updatedAt: Date.now(), 84 | }) 85 | await knex('Content').insert({ 86 | resourceId: resourceId3, 87 | name: 'Demo Content #5', 88 | title: 'Donec nec placerat purus', 89 | description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean egestas id quam eget rutrum.', 90 | data: JSON.stringify({ 91 | foo: 'bar', 92 | }), 93 | status: 0, 94 | createdAt: Date.now(), 95 | updatedAt: Date.now(), 96 | }) 97 | } 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hapi-boilerplate", 3 | "version": "0.0.1", 4 | "description": "Hapi.js boilerplate", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/vietthang/hapi-boilerplate.git" 9 | }, 10 | "keywords": [ 11 | "hapi", 12 | "boilerplate", 13 | "promise", 14 | "async" 15 | ], 16 | "author": "Yoshi", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/vietthang/hapi-boilerplate/issues" 20 | }, 21 | "homepage": "https://github.com/vietthang/hapi-boilerplate#readme", 22 | "scripts": { 23 | "build:production": "NODE_ENV=production webpack --config scripts/webpack.config.production.js", 24 | "build:db": "NODE_ENV=production webpack --config scripts/webpack.config.db.js", 25 | "build": "npm run build:production && npm run build:db", 26 | "watch:dev": "webpack --colors --progress --config scripts/webpack.config.development.js --watch", 27 | "build:dev": "webpack --colors --progress --config scripts/webpack.config.development.js", 28 | "watch:test": "NODE_ENV=test webpack --colors --progress --config scripts/webpack.config.test.js --watch", 29 | "build:test": "NODE_ENV=test webpack --colors --progress --config scripts/webpack.config.test.js", 30 | "watch": "concurrently --kill-others \"npm run watch:dev\" \"npm run watch:test\"", 31 | "server": "BLUEBIRD_DEBUG=1 NODE_ENV=development forever --minUptime 1000 --spinSleepTime 1000 --workingDir $(pwd) --watch --watchDirectory build/src index.js", 32 | "mocha": "BLUEBIRD_DEBUG=1 NODE_ENV=test mocha --colors --recursive --reporter progress ./build/test", 33 | "mocha:junit": "BLUEBIRD_DEBUG=1 NODE_ENV=test mocha --colors --recursive --reporter mocha-junit-reporter ./build/test", 34 | "dev": "concurrently --kill-others \"npm run watch\" \"npm run server\"", 35 | "prepare": "npm run clean && mkdir -p build && npm run migrate:latest", 36 | "clean": "rm -rf build/*", 37 | "lint": "eslint .", 38 | "test": "npm run lint && npm run build:test && npm run coverage", 39 | "test:junit": "npm run lint && npm run build:test && npm run coverage:junit", 40 | "start": "node index.js", 41 | "coverage": "nyc npm run mocha", 42 | "coverage:junit": "nyc npm run mocha:junit", 43 | "coverage:report": "nyc report", 44 | "migrate:make": "ROOT_DIR=$(pwd) `npm bin`/babel-node `npm bin`/knex --knexfile src/db/knexfile.js migrate:make", 45 | "migrate:latest": "ROOT_DIR=$(pwd) `npm bin`/babel-node `npm bin`/knex --knexfile src/db/knexfile.js migrate:latest", 46 | "migrate:rollback": "ROOT_DIR=$(pwd) `npm bin`/babel-node `npm bin`/knex --knexfile src/db/knexfile.js migrate:rollback", 47 | "seed:make": "ROOT_DIR=$(pwd) `npm bin`/babel-node `npm bin`/knex --knexfile src/db/knexfile.js seed:make", 48 | "seed:run": "ROOT_DIR=$(pwd) `npm bin`/babel-node `npm bin`/knex --knexfile src/db/knexfile.js seed:run", 49 | "migrate:latest:prebuilt": "knex --knexfile lib/db/knexfile.js migrate:latest", 50 | "migrate:rollback:prebuilt": "knex --knexfile lib/db/knexfile.js migrate:rollback", 51 | "eb:deploy:production": "npm run build && eb deploy" 52 | }, 53 | "devDependencies": { 54 | "babel-cli": "^6.18.0", 55 | "babel-core": "6.18.2", 56 | "babel-eslint": "7.1.0", 57 | "babel-loader": "6.2.7", 58 | "babel-plugin-add-module-exports": "^0.2.1", 59 | "babel-plugin-istanbul": "^2.0.3", 60 | "babel-plugin-lodash": "^3.2.9", 61 | "babel-plugin-source-map-support-for-6": "0.0.5", 62 | "babel-preset-babili": "0.0.8", 63 | "babel-preset-env": "0.0.7", 64 | "babel-preset-stage-2": "^6.18.0", 65 | "chai": "^3.5.0", 66 | "chai-as-promised": "^6.0.0", 67 | "concurrently": "^3.1.0", 68 | "eslint": "^3.9.1", 69 | "eslint-config-airbnb": "^12.0.0", 70 | "eslint-plugin-import": "1.16.0", 71 | "eslint-plugin-jsx-a11y": "^2.2.3", 72 | "eslint-plugin-react": "^6.5.0", 73 | "forever": "^0.15.3", 74 | "json-schema-loader": "0.0.3", 75 | "mocha": "3.1.2", 76 | "nyc": "^8.4.0", 77 | "webpack": "1.13.3", 78 | "webpack-node-externals": "^1.5.4" 79 | }, 80 | "nyc": { 81 | "include": [ 82 | "src/**/*.js" 83 | ], 84 | "sourceMap": false, 85 | "instrument": false 86 | }, 87 | "dependencies": { 88 | "bluebird": "^3.4.6", 89 | "bookshelf": "^0.10.2", 90 | "boom": "^4.2.0", 91 | "confidence": "^3.0.2", 92 | "good": "^7.0.2", 93 | "good-console": "^6.3.1", 94 | "hapi": "^15.2.0", 95 | "hapi-swaggered-ui": "^2.5.1", 96 | "inert": "^4.0.2", 97 | "joi": "^9.2.0", 98 | "knex": "^0.12.6", 99 | "lodash": "^4.16.6", 100 | "mysql": "^2.12.0", 101 | "overjoy-await": "0.0.5", 102 | "overjoy-swag": "^1.0.16", 103 | "redis": "^2.6.3", 104 | "source-map-support": "^0.4.6", 105 | "sqlite3": "^3.1.8", 106 | "swagger-parser": "^3.4.1", 107 | "uuid": "^2.0.3", 108 | "vision": "^4.1.0" 109 | }, 110 | "optionalDependencies": { 111 | "bugger": "^2.3.0", 112 | "mocha-junit-reporter": "^1.12.1" 113 | }, 114 | "engines": { 115 | "node": ">=6.2.2" 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/components/orm.js: -------------------------------------------------------------------------------- 1 | import bookshelf from 'bookshelf' 2 | import { Promise } from 'bluebird' 3 | import { 4 | pipe, omitBy, isNil, omit, toPairs, fromPairs, has, filter, memoize, map, isObjectLike, isString, isArray, isFunction, 5 | } from 'lodash/fp' 6 | import { validate } from 'overjoy-swag' 7 | 8 | import resolveAllOf from './resolveAllOf' 9 | import knex from '../components/knex' 10 | 11 | const instance = bookshelf(knex) 12 | instance.plugin('registry') 13 | instance.plugin('virtuals') 14 | instance.plugin('visibility') 15 | 16 | // require all models in 'models' 17 | const requireModels = require.context('../models', false, /\.js$/) 18 | 19 | const oldInstanceModel = instance.model.bind(instance) 20 | instance.model = (modelName) => { 21 | const storedModel = oldInstanceModel(modelName) 22 | 23 | if (!storedModel) { 24 | return oldInstanceModel(modelName, requireModels(`./${modelName}.js`)) 25 | } 26 | 27 | return storedModel 28 | } 29 | 30 | const BookshelfModel = instance.Model 31 | 32 | const validateAsync = Promise.promisify(validate) 33 | 34 | class ValidationError extends Error { 35 | 36 | } 37 | 38 | async function doValidate(model, schema) { 39 | const attributes = pipe( 40 | omitBy(value => isNil(value)), 41 | omit(model.idAttribute), // never update the id field 42 | )(model.attributes) 43 | 44 | await validateAsync( 45 | schema, 46 | attributes, 47 | ) 48 | 49 | const modelClass = model.constructor 50 | if (has('relations', modelClass)) { 51 | if (isArray(modelClass.relations)) { 52 | await Promise.map(modelClass.relations, async (relationName) => { 53 | const relationFunction = model[relationName] 54 | if (!isFunction(relationFunction)) { 55 | throw new ValidationError(`Property ${relationName} in ${model.tableName} is not a function.`) 56 | } 57 | 58 | const relation = model[relationName]().relatedData 59 | // only support "belongsTo" now 60 | if (relation.type === 'belongsTo') { 61 | const { targetTableName, targetIdAttribute } = relation 62 | const modelForeignKey = model.get(relation.foreignKey) 63 | 64 | const { count } = await knex 65 | .table(targetTableName) 66 | .where(targetIdAttribute, modelForeignKey) 67 | .count('id AS count') 68 | .first() 69 | 70 | if (count !== 1) { 71 | throw new ValidationError( 72 | `${targetTableName} with ${targetIdAttribute}=${modelForeignKey} not found` 73 | ) 74 | } 75 | } 76 | }) 77 | } else { 78 | throw new ValidationError(`${model.tableName} model attribute relations is not an array.`) 79 | } 80 | } 81 | } 82 | 83 | const getResolvedSchema = memoize(schema => ({ 84 | ...resolveAllOf(schema), 85 | additionalProperties: false, 86 | })) 87 | 88 | export default class Model extends BookshelfModel { 89 | 90 | constructor(...args) { 91 | super(...args) 92 | 93 | const modelClass = this.constructor 94 | 95 | if (modelClass.schema) { 96 | const schema = getResolvedSchema(modelClass.schema) 97 | 98 | // console.log('schema', schema) 99 | 100 | this.on('creating', model => doValidate(model, schema)) 101 | this.on('updating', model => doValidate(model, schema)) 102 | } 103 | 104 | this.on('creating', () => { 105 | this.set('createdAt', Date.now()) 106 | this.set('updatedAt', Date.now()) 107 | }) 108 | 109 | this.on('updating', () => { 110 | this.set('updatedAt', Date.now()) 111 | }) 112 | } 113 | 114 | parse(attributes) { 115 | const schema = getResolvedSchema(this.constructor.schema) 116 | 117 | if (schema) { 118 | return pipe( 119 | super.parse.bind(this), 120 | toPairs, 121 | // only get keys from schema to the model 122 | filter(([key]) => has(key, schema.properties)), 123 | map(([key, value]) => { 124 | // cast to boolean if key has type of boolean 125 | if (schema.properties[key].type === 'boolean') { 126 | return [key, !!value] 127 | } 128 | 129 | // parse to json if schema key has type of object 130 | if (schema.properties[key].type === 'object' && isString(value)) { 131 | return [key, JSON.parse(value)] 132 | } 133 | 134 | return [key, value] 135 | }), 136 | fromPairs, 137 | )(attributes) 138 | } else { 139 | return super.parse(attributes) 140 | } 141 | } 142 | 143 | format(attributes) { 144 | const schema = getResolvedSchema(this.constructor.schema) 145 | 146 | if (schema) { 147 | return pipe( 148 | super.parse.bind(this), 149 | toPairs, 150 | // only get keys from schema to the model 151 | filter(([key]) => has(key, schema.properties)), 152 | map(([key, value]) => { 153 | // convert to string if schema key has type of object 154 | if (schema.properties[key].type === 'object' && isObjectLike(value)) { 155 | return [key, JSON.stringify(value)] 156 | } 157 | 158 | return [key, value] 159 | }), 160 | fromPairs, 161 | )(attributes) 162 | } else { 163 | return super.format(attributes) 164 | } 165 | } 166 | 167 | serialize(options) { 168 | return pipe( 169 | super.serialize.bind(this), 170 | omitBy(value => isNil(value)), 171 | )(options) 172 | } 173 | 174 | } 175 | 176 | Model.ValidationError = ValidationError 177 | -------------------------------------------------------------------------------- /test/routes/resources.inc.js: -------------------------------------------------------------------------------- 1 | import { omit, isMatch, isNumber } from 'lodash/fp' 2 | import uuid from 'uuid' 3 | 4 | import assert from '../common/assert' 5 | 6 | export default (server) => { 7 | const INVALID_ID = uuid.v4() 8 | 9 | let resource 10 | 11 | describe('Test resources routes', () => { 12 | describe('Test resource create', () => { 13 | const validPayload = { 14 | name: 'input.mp4', 15 | status: 1, 16 | } 17 | 18 | const validRequest = { 19 | method: 'post', 20 | url: '/api/v1/resources', 21 | payload: validPayload, 22 | } 23 | 24 | it('Should reject if missing \'name\'', async () => { 25 | const ret = await server.inject({ 26 | ...validRequest, 27 | payload: omit('name', validPayload), 28 | }) 29 | 30 | assert.equal(ret.statusCode, 400) 31 | }) 32 | 33 | it('Should success submit valid payload', async () => { 34 | const ret = await server.inject(validRequest) 35 | 36 | assert.equal(ret.statusCode, 200) 37 | resource = JSON.parse(ret.payload) 38 | assert(isMatch(validPayload, resource)) 39 | }) 40 | }) 41 | 42 | describe('Test resource find', () => { 43 | it('Should get list of resource with recently submit data', async () => { 44 | const ret = await server.inject({ 45 | method: 'get', 46 | url: '/api/v1/resources', 47 | }) 48 | 49 | assert.equal(ret.statusCode, 200) 50 | assert(isNumber(ret.headers['x-meta-total'])) 51 | const resources = JSON.parse(ret.payload) 52 | assert(resources.find(entry => entry.id === resource.id)) 53 | }) 54 | }) 55 | 56 | describe('Test get resource by ID', () => { 57 | it('Should reject with 400 if get by an bad format ID', async () => { 58 | const ret = await server.inject({ 59 | method: 'get', 60 | url: '/api/v1/resources/0-0', 61 | }) 62 | 63 | assert.equal(ret.statusCode, 400) 64 | }) 65 | 66 | it('Should reject with 404 if get by an invalid ID', async () => { 67 | const ret = await server.inject({ 68 | method: 'get', 69 | url: `/api/v1/resources/${INVALID_ID}`, 70 | }) 71 | 72 | assert.equal(ret.statusCode, 404) 73 | }) 74 | 75 | it('Shoud success and get the stored resource if submit created ID', async () => { 76 | const ret = await server.inject({ 77 | method: 'get', 78 | url: `/api/v1/resources/${resource.id}`, 79 | }) 80 | 81 | assert.equal(ret.statusCode, 200) 82 | const storedResource = JSON.parse(ret.payload) 83 | assert.deepEqual(resource, storedResource) 84 | }) 85 | }) 86 | 87 | describe('Test update resource by ID', () => { 88 | const validPayload = { 89 | name: 'input2.mp4', 90 | status: 0, 91 | } 92 | 93 | const validRequest = { 94 | method: 'put', 95 | get url() { return `/api/v1/resources/${resource.id}` }, // deffered getter 96 | payload: validPayload, 97 | } 98 | 99 | it('Should reject with 400 if get by an bad format ID', async () => { 100 | const ret = await server.inject({ 101 | ...validRequest, 102 | url: '/api/v1/resources/0-0', 103 | }) 104 | 105 | assert.equal(ret.statusCode, 400) 106 | }) 107 | 108 | it('Should reject with 404 if get by an invalid ID', async () => { 109 | const ret = await server.inject({ 110 | ...validRequest, 111 | url: `/api/v1/resources/${INVALID_ID}`, 112 | }) 113 | 114 | assert.equal(ret.statusCode, 404) 115 | }) 116 | 117 | it('Shoud success and get the stored resource if submit valid ID', async () => { 118 | const ret = await server.inject(validRequest) 119 | 120 | assert.equal(ret.statusCode, 200) 121 | const storedResource = JSON.parse(ret.payload) 122 | assert.deepEqual(omit('updatedAt', { 123 | ...resource, 124 | ...validPayload, 125 | }), omit('updatedAt', storedResource)) // compare 2 instances without updatedAt field 126 | }) 127 | }) 128 | 129 | describe('Test delete resource by ID', () => { 130 | const validRequest = { 131 | method: 'delete', 132 | get url() { return `/api/v1/resources/${resource.id}` }, // deffered getter 133 | } 134 | 135 | it('Should reject with 400 if delete by an bad format ID', async () => { 136 | const ret = await server.inject({ 137 | ...validRequest, 138 | url: '/api/v1/resources/0-0', 139 | }) 140 | 141 | assert.equal(ret.statusCode, 400) 142 | }) 143 | 144 | it('Should reject with 404 if delete by an invalid ID', async () => { 145 | const ret = await server.inject({ 146 | ...validRequest, 147 | url: `/api/v1/resources/${INVALID_ID}`, 148 | }) 149 | 150 | assert.equal(ret.statusCode, 404) 151 | }) 152 | 153 | it('Shoud success if delete by a valid ID', async () => { 154 | const ret = await server.inject(validRequest) 155 | 156 | assert.equal(ret.statusCode, 204) 157 | }) 158 | 159 | it('Shoud reject with 404 if delete by a deleted ID', async () => { 160 | const ret = await server.inject(validRequest) 161 | 162 | assert.equal(ret.statusCode, 404) 163 | }) 164 | 165 | it('Shoud reject with 404 if get by a deleted ID', async () => { 166 | const ret = await server.inject({ 167 | ...validRequest, 168 | method: 'get', 169 | }) 170 | 171 | assert.equal(ret.statusCode, 404) 172 | }) 173 | }) 174 | }) 175 | } 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hapi-boilerplate 2 | 3 | Hapi.js boilerplate 4 | 5 | ## Installation 6 | 7 | Download node at [nodejs.org](http://nodejs.org) and install it, if you haven't already. 8 | 9 | ```sh 10 | npm install 11 | ``` 12 | 13 | ## Tests 14 | 15 | ```sh 16 | npm install 17 | npm test # check for code style, run all test cases and generate coverage report 18 | ``` 19 | 20 | ## Build (production) 21 | ```sh 22 | npm build 23 | ``` 24 | 25 | ## Start (production) 26 | ```sh 27 | npm start --production 28 | ``` 29 | 30 | ## Development 31 | 32 | ### Before run 33 | 34 | Setup database as in file `/src/db/knexfile.js`, 'development' config. Then run 35 | 36 | ``` 37 | npm run migrate:latest 38 | npm run seed:run 39 | ``` 40 | 41 | ### Run 42 | 43 | Quick start: 44 | ``` 45 | npm run dev # run build & server, also watch source files for rebuild & restart server 46 | ``` 47 | 48 | Or run each for following command in separate command line tabs: 49 | ``` 50 | npm run watch # run build and continuously watch files for changed and rebuild 51 | ``` 52 | 53 | ``` 54 | npm run server # run server and auto reload when source files change 55 | ``` 56 | 57 | ``` 58 | npm run mocha # run all test cases 59 | ``` 60 | 61 | ## Database migrations 62 | Read [knex](http://knexjs.org/#Migrations) for more information. 63 | 64 | ### Create new migration file 65 | ``` 66 | npm run migrate:make [name] 67 | ``` 68 | 69 | ### Update database to latest version 70 | ``` 71 | npm run migrate:latest 72 | ``` 73 | 74 | ### Rollback a version of database 75 | ``` 76 | npm run migrate:rollback 77 | ``` 78 | 79 | ### Make a new seed file 80 | ``` 81 | npm run seed:make [name] 82 | ``` 83 | 84 | ### Run seed files 85 | ``` 86 | npm run seed:run 87 | ``` 88 | 89 | ## Project structure 90 | ``` 91 | └ project 92 | ├ src/ # <-- Directory contains all source code 93 | | | 94 | | ├ components/ # <-- Directory contains all helpers/utilities 95 | | | # which don't depend on hapi.js 96 | | | | 97 | | | ├ config.js # <-- Helper to load config from config.js file and 98 | | | | # parse base on NODE_ENV environment variable. 99 | | | | # Using confidence. 100 | | | | 101 | | | ├ knex.js # <-- Return an instance of knex. 102 | | | | 103 | | | ├ orm.js # <-- Simple module to wrap bookshelf Model to 104 | | | | # provide some utilities functions like 105 | | | | # validation. 106 | | | | 107 | | | ├ redis.js # <-- Return an instance of redis. Also provide 108 | | | | # promise interface. 109 | | | | 110 | | | ├ resolveAllOf.js # <-- Helper to resolve "allOf" in json schema. 111 | | | | 112 | | | └ ... 113 | | | 114 | | ├ controllers/ # <-- Directory contains all controllers (hapi 115 | | | # handler functions) 116 | | | 117 | | ├ db/ # <-- Directory contains db configuration, 118 | | | | # migrations, seed files to work with both 119 | | | | # application and knex command line. 120 | | | | 121 | | | ├ migrations/ # <-- Directory contains migration files. Read 122 | | | | # knex.js docs for more information. 123 | | | | 124 | | | ├ seeds/ # <-- Directory contains seed files. Read knex.js 125 | | | | # docs for more information. 126 | | | | 127 | | | └ knexfile.js # <-- knex configuration file. Read knex.js docs 128 | | | # for more information. 129 | | | 130 | | ├ models/ # <-- Directory contains all models. 131 | | | 132 | | ├ modules/ # <-- Directory contains all modules which work 133 | | | | # as hapi.js plugin 134 | | | | 135 | | | ├ apiLoader.js # <-- Loader swagger config and generate hapi.js 136 | | | | # route config. 137 | | | | 138 | | | └ ... 139 | | | 140 | | ├ schemas/ # <-- Directory contains schemas of api & models. 141 | | | | 142 | | | ├ api.swagger.yaml # <-- Root api file, defines all routes here. 143 | | | | 144 | | | ├ common.yaml # <-- Include common parameters for api. 145 | | | | 146 | | | └ models/ # <-- Directory contains all models definitions. 147 | | | | 148 | | | ├ common.yaml # <-- Include common properties for all models. 149 | | | | 150 | | | └ ... 151 | | | 152 | | ├ config.js # <-- Application config file, using "confidence". 153 | | | 154 | | ├ index.js # <-- Application entry, contains "main" function, 155 | | | # load hapi.js server(with plugins) and start. 156 | | | 157 | | └ routes.js # <-- Mapping from swagger operationId to handler 158 | | # method in controllers. 159 | | 160 | ├ test/ # <-- Directory contains all test files. (Files 161 | | | # with name "*.spec.js" will be run when test) 162 | | | 163 | | ├ common/ # <-- Directory contains some helpers included 164 | | | # in many test cases. 165 | | | 166 | | └ ... 167 | | 168 | ├ scripts/ # <-- Directory contains webpack build scripts. 169 | | | # (Some advanced & confusing shit.) 170 | | | 171 | | └ ... 172 | | 173 | ├ dist/ # <-- Auto generated directory contains compiled 174 | | # library to use in production. 175 | | 176 | ├ build/ # <-- Auto generated directory contains compiled 177 | | # artifacts for test & dev. 178 | | 179 | ├ coverage/ # <-- Auto generated directory contains code 180 | | # coverage information. 181 | | 182 | ├ .babelrc # <-- Babel configuration file. 183 | | 184 | ├ .eslintignore # <-- ESLint ignore file. 185 | | 186 | ├ .eslintrc # <-- ESLint configuration file. 187 | | 188 | ├ index.js # <-- Entry file. Load in build if NODE_ENV is 189 | | # "development" or lib if NODE_ENV is "production" 190 | | 191 | ├ package.json # <-- npm configuration file. Contains magic. 192 | | 193 | ├ README.md # <-- This stupid file. 194 | | 195 | └ ... 196 | ``` 197 | 198 | ## License 199 | 200 | MIT 201 | -------------------------------------------------------------------------------- /src/schemas/api.swagger.yaml: -------------------------------------------------------------------------------- 1 | swagger: '2.0' 2 | info: 3 | version: 1.0.0 4 | title: Hapi boilerplate 5 | basePath: /api/v1 6 | schemes: 7 | - http 8 | - https 9 | consumes: 10 | - application/json 11 | - application/x-www-form-urlencoded 12 | - multipart/form-data 13 | - text/plain 14 | produces: 15 | - application/json 16 | paths: 17 | /resources: 18 | get: 19 | operationId: findResource 20 | description: Get list of resources 21 | tags: 22 | - resource 23 | parameters: 24 | - $ref: './common.yaml#/parameters/LimitQuery' 25 | - $ref: './common.yaml#/parameters/OffsetQuery' 26 | - $ref: './common.yaml#/parameters/OrderByQuery' 27 | - $ref: './common.yaml#/parameters/OrderDirectionQuery' 28 | responses: 29 | 200: 30 | description: List of Resource 31 | headers: 32 | x-meta-total: 33 | type: integer 34 | minimum: 0 35 | description: Get total number of entries in database 36 | schema: 37 | type: array 38 | items: 39 | $ref: './models/resource.yaml#/get' 40 | post: 41 | operationId: createResource 42 | description: Create new resource 43 | tags: 44 | - resource 45 | parameters: 46 | - name: model 47 | in: body 48 | required: true 49 | schema: 50 | $ref: './models/resource.yaml#/create' 51 | responses: 52 | 200: 53 | description: Resource response 54 | schema: 55 | $ref: './models/resource.yaml#/get' 56 | 57 | /resources/{id}: 58 | get: 59 | operationId: getResource 60 | description: Get resource by ID 61 | tags: 62 | - resource 63 | parameters: 64 | - $ref: '#/parameters/ResourceIdParam' 65 | responses: 66 | 200: 67 | description: Resource response 68 | schema: 69 | $ref: './models/resource.yaml#/get' 70 | put: 71 | operationId: updateResource 72 | description: Update resource by ID 73 | tags: 74 | - resource 75 | parameters: 76 | - $ref: '#/parameters/ResourceIdParam' 77 | - name: model 78 | in: body 79 | required: true 80 | schema: 81 | $ref: './models/resource.yaml#/update' 82 | responses: 83 | 200: 84 | description: Resource response 85 | schema: 86 | $ref: './models/resource.yaml#/get' 87 | delete: 88 | operationId: deleteResource 89 | description: Delete resource by ID 90 | tags: 91 | - resource 92 | parameters: 93 | - $ref: '#/parameters/ResourceIdParam' 94 | responses: 95 | 204: 96 | description: Resource deleted 97 | 98 | /resources/{id}/url: 99 | get: 100 | operationId: getResourceUrl 101 | description: Get the link to object(file) stored in resource 102 | tags: 103 | - resource 104 | parameters: 105 | - $ref: '#/parameters/ResourceIdParam' 106 | - name: operation 107 | in: query 108 | type: string 109 | enum: 110 | - get 111 | - put 112 | default: get 113 | responses: 114 | 200: 115 | description: Url response 116 | schema: 117 | $ref: '#/definitions/UrlResponse' 118 | 119 | /contents: 120 | get: 121 | operationId: findContent 122 | description: Get list of contents 123 | tags: 124 | - content 125 | parameters: 126 | - $ref: './common.yaml#/parameters/LimitQuery' 127 | - $ref: './common.yaml#/parameters/OffsetQuery' 128 | - $ref: './common.yaml#/parameters/OrderByQuery' 129 | - $ref: './common.yaml#/parameters/OrderDirectionQuery' 130 | responses: 131 | 200: 132 | description: List of Content 133 | headers: 134 | x-meta-total: 135 | type: integer 136 | minimum: 0 137 | description: Get total number of entries in database 138 | schema: 139 | type: array 140 | items: 141 | $ref: './models/content.yaml#/get' 142 | post: 143 | operationId: createContent 144 | description: Create new content 145 | tags: 146 | - content 147 | parameters: 148 | - name: model 149 | in: body 150 | required: true 151 | schema: 152 | $ref: './models/content.yaml#/create' 153 | responses: 154 | 200: 155 | description: Content response 156 | schema: 157 | $ref: './models/content.yaml#/get' 158 | 159 | /contents/{id}: 160 | get: 161 | operationId: getContent 162 | description: Get content by ID 163 | tags: 164 | - content 165 | parameters: 166 | - $ref: '#/parameters/ContentIdParam' 167 | responses: 168 | 200: 169 | description: Content response 170 | schema: 171 | $ref: './models/content.yaml#/get' 172 | put: 173 | operationId: updateContent 174 | description: Update content by ID 175 | tags: 176 | - content 177 | parameters: 178 | - $ref: '#/parameters/ContentIdParam' 179 | - name: model 180 | in: body 181 | required: true 182 | schema: 183 | $ref: './models/content.yaml#/update' 184 | responses: 185 | 200: 186 | description: Content response 187 | schema: 188 | $ref: './models/content.yaml#/get' 189 | delete: 190 | operationId: deleteContent 191 | description: Delete content by ID 192 | tags: 193 | - content 194 | parameters: 195 | - $ref: '#/parameters/ContentIdParam' 196 | responses: 197 | 204: 198 | description: Content deleted 199 | 200 | parameters: 201 | ResourceIdParam: 202 | name: id 203 | in: path 204 | required: true 205 | type: string 206 | format: uuid 207 | QueueNameParam: 208 | name: name 209 | in: path 210 | required: true 211 | type: string 212 | minLength: 1 213 | maxLength: 32 214 | TaskIdParam: 215 | name: id 216 | in: path 217 | required: true 218 | type: string 219 | format: uuid 220 | ContentIdParam: 221 | name: id 222 | in: path 223 | required: true 224 | type: integer 225 | format: int32 226 | definitions: 227 | UrlResponse: 228 | type: object 229 | properties: 230 | url: 231 | type: string 232 | format: url 233 | taskId: 234 | type: string 235 | format: uuid 236 | required: 237 | - url 238 | --------------------------------------------------------------------------------