diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..25889c0
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,18 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Launch Program",
+ "program": "${workspaceFolder}/bin/www.js",
+ "console": "integratedTerminal",
+ "env": {
+ "NODE_ENV": "staging"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/api/app.js b/api/app.js
new file mode 100644
index 0000000..a7b22b9
--- /dev/null
+++ b/api/app.js
@@ -0,0 +1,30 @@
+const express = require('express')
+const expressWS = require('express-ws')
+const morgan = require('morgan')
+const logger = require('../logger')
+const bodyParser = require('body-parser')
+const serveStatic = require('serve-static')
+const users = require('./users')
+const ws = require('./ws')
+
+const app = express()
+app.websocket = expressWS(app)
+
+app.use(morgan('combined', { stream: logger.stream }))
+
+app.use(bodyParser.json())
+app.use(bodyParser.urlencoded({ extended: true }))
+
+app.use(function (req, res, next) {
+ res.header('Access-Control-Allow-Origin', '*')
+ res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
+ res.header('Access-Control-Allow-Headers', 'content-type')
+ next()
+})
+
+app.use('/api/users', users)
+
+app.use('/', serveStatic(`${__dirname}/public`))
+app.ws('/ws', ws)
+
+module.exports = app
diff --git a/api/public/client.js b/api/public/client.js
new file mode 100644
index 0000000..0539c9d
--- /dev/null
+++ b/api/public/client.js
@@ -0,0 +1,23 @@
+(function () {
+ const HOST = location.origin.replace(/^http/, 'ws')
+ const ws = new WebSocket(HOST + '/ws')
+
+ const form = document.querySelector('.form')
+
+ form.onsubmit = function () {
+ const input = document.querySelector('.input')
+ const text = input.value
+ ws.send(text)
+ input.value = ''
+ input.focus()
+ return false
+ }
+
+ ws.onmessage = function (msg) {
+ const response = msg.data
+ const messageList = document.querySelector('.messages')
+ const li = document.createElement('li')
+ li.textContent = response
+ messageList.appendChild(li)
+ }
+}())
diff --git a/api/public/index.html b/api/public/index.html
new file mode 100644
index 0000000..777a2ce
--- /dev/null
+++ b/api/public/index.html
@@ -0,0 +1,15 @@
+
+
+
+ API server serve chat on WebSocket
+
+
+ API server serve chat on WebSocket
+
+
+
+
+
diff --git a/api/users/index.js b/api/users/index.js
new file mode 100644
index 0000000..22c82f7
--- /dev/null
+++ b/api/users/index.js
@@ -0,0 +1,12 @@
+const express = require('express')
+const ctrl = require('./users.ctrl')
+
+const router = express.Router()
+
+router.post('/', ctrl.creates)
+router.get('/', ctrl.readsAll)
+router.get('/:id', ctrl.reads)
+router.put('/:id', ctrl.updates)
+router.delete('/:id', ctrl.deletes)
+
+module.exports = router
diff --git a/api/users/users.ctrl.js b/api/users/users.ctrl.js
new file mode 100644
index 0000000..f712b1f
--- /dev/null
+++ b/api/users/users.ctrl.js
@@ -0,0 +1,147 @@
+const logger = require('../../logger')
+const config = require('config')
+const db = require('../../db/db')
+
+// CRUD: to users
+
+class ErrorCode extends Error {
+ constructor (code = 'GENERIC', status = 500, ...params) {
+ super(...params)
+
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, ErrorCode)
+ }
+
+ this.code = code
+ this.status = status
+ }
+}
+
+const creates = function (req, res) {
+ db.transaction(async function (sql) {
+ const name = req.body.name
+ if (!name) {
+ throw new ErrorCode('NO_NAME', 400, `failed to parse: name=${name}`)
+ }
+
+ const rows = await sql.select('*').from('users').where('name', name)
+ logger.debug(rows.length) // 1 | 0
+ if (rows.length) {
+ throw new ErrorCode('ALREADY_EXISTS', 409, `already exists: rows.length=${rows.length}`)
+ }
+
+ const total = await sql.insert({ name: name }).into('users')
+ logger.debug(total) // [4]
+ if (!total) {
+ throw new ErrorCode('NO_INSERT', 404, `failed to insert: total=${total}`)
+ }
+
+ res.status(201).json({ total: total, name: name })
+ }).catch(function (exc) {
+ logger.warn(exc.message)
+ let status = 404
+ if (exc instanceof ErrorCode) status = exc.status
+ res.status(status).end()
+ })
+}
+
+const reads = function (req, res) {
+ db.transaction(async function (sql) {
+ const id = parseInt(req.params.id, 10)
+ if (Number.isNaN(id)) {
+ throw new ErrorCode('NO_NUMBER', 400, `failed to parse: id=${id}`)
+ }
+
+ const rows = await sql.select('*').from('users').where('id', id)
+ const exists = rows.length
+ if (!exists) {
+ throw new ErrorCode('NO_EXIST', 404, `failed to select: id=${id}`)
+ }
+
+ const user = rows[0]
+ logger.debug(user) // { id: 1, name: 'Alice' }
+ return res.json(user)
+ }).catch(function (exc) {
+ logger.warn(exc.message)
+ let status = 404
+ if (exc instanceof ErrorCode) status = exc.status
+ return res.status(status).end()
+ })
+}
+
+const readsAll = function (req, res) {
+ db.transaction(async function (sql) {
+ req.query.limit = req.query.limit || config.get('API.users.limit')
+
+ const limit = parseInt(req.query.limit, 10)
+ if (Number.isNaN(limit)) {
+ throw new ErrorCode('NO_NUMBER', 400, `failed to parse: limit=${limit}`)
+ }
+
+ const rows = await sql.select('*').from('users').limit(limit)
+ logger.debug(rows) // [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bek' } ]
+ return res.json(rows)
+ }).catch(function (exc) {
+ logger.warn(exc.message)
+ let status = 404
+ if (exc instanceof ErrorCode) status = exc.status
+ return res.status(status).end()
+ })
+}
+
+const updates = function (req, res) {
+ db.transaction(async function (sql) {
+ const id = parseInt(req.params.id, 10)
+ if (Number.isNaN(id)) {
+ throw new ErrorCode('NO_NUMBER', 404, `failed to parse: id=${id}`)
+ }
+
+ const name = req.body.name
+ if (!name) {
+ throw new ErrorCode('NO_NAME', 400, `failed to parse: name=${name}`)
+ }
+
+ const updated = await sql.update('name', name).from('users').where('id', id)
+ logger.debug(updated) // 1 | 0
+ if (!updated) {
+ throw new ErrorCode('NO_UPDATE', 404, `failed to update: id=${id}`)
+ }
+
+ return res.status(204).end()
+ }).catch(function (exc) {
+ logger.warn(exc.message)
+ let status = 404
+ if (exc instanceof ErrorCode) status = exc.status
+ return res.status(status).end()
+ })
+}
+
+const deletes = function (req, res) {
+ db.transaction(async function (sql) {
+ const id = parseInt(req.params.id, 10)
+ if (Number.isNaN(id)) {
+ throw new ErrorCode('NO_NUMBER', 400, `failed to parse: id=${id}`)
+ }
+
+ const deleted = await sql.del().from('users').where('id', id)
+ logger.debug(deleted) // 1 | 0
+ if (!deleted) {
+ throw new ErrorCode('NO_DELETE', 404, `failed to delete: id=${id}`)
+ }
+
+ return res.status(204).end()
+ }).catch(function (exc) {
+ logger.warn(exc.message)
+ let status = 404
+ if (exc instanceof ErrorCode) status = exc.status
+ return res.status(status).end()
+ })
+}
+
+module.exports = {
+ creates,
+ reads,
+ readsAll,
+ updates,
+ deletes
+}
diff --git a/api/users/users.spec.js b/api/users/users.spec.js
new file mode 100644
index 0000000..020c761
--- /dev/null
+++ b/api/users/users.spec.js
@@ -0,0 +1,165 @@
+require('should')
+const request = require('supertest')
+const app = require('../app')
+const db = require('../../db/db')
+const seeding = require('../../db/seeds/development/users')
+
+describe('CHK database', function () {
+ it('데이터베이스를 준비한다', function (done) {
+ seeding.seed(db)
+ done()
+ })
+})
+
+describe('GET /api/users', function () {
+ context('성공한 경우', function () {
+ it('배열을 반환한다', function (done) {
+ request(app)
+ .get('/api/users/')
+ .end(function (_err, res) {
+ // console.log(res.body)
+ res.body.should.be.instanceof(Array)
+ res.body.forEach(function (user) {
+ user.should.have.property('name')
+ })
+ done()
+ })
+ })
+ it('최대 limit 갯수만큼 응답한다', function (done) {
+ const limit = 2
+ request(app)
+ .get(`/api/users?limit=${limit}`)
+ .end(function (_err, res) {
+ res.body.should.have.lengthOf(limit)
+ done()
+ })
+ })
+ })
+
+ context('실패한 경우', function () {
+ it('limit이 정수가 아니면 400 응답한다', function (done) {
+ request(app)
+ .get('/api/users?limit=two')
+ .expect(400)
+ .end(done)
+ })
+ })
+})
+
+describe('GET /api/users/:id', function () {
+ context('성공한 경우', function () {
+ it('유저 객체를 반환한다', function (done) {
+ const id = 1
+ request(app)
+ .get(`/api/users/${id}`)
+ .end(function (_err, res) {
+ // console.log(res.body)
+ res.body.should.have.property('id', id)
+ done()
+ })
+ })
+ })
+
+ context('실패한 경우', function () {
+ it('id가 정수가 아니면 400 응답한다', function (done) {
+ request(app)
+ .get('/api/users/two')
+ .expect(400)
+ .end(done)
+ })
+ it('찾을 수 없는 id일 경우 404 응답한다', function (done) {
+ const id = 5
+ request(app)
+ .get(`/api/users/${id}`)
+ .expect(404)
+ .end(done)
+ })
+ })
+})
+
+describe('POST /api/users', function () {
+ context('성공한 경우', function () {
+ it('201 응답, 생성한 유저 객체를 응답한다', function (done) {
+ request(app)
+ .post('/api/users').send({ name: 'Daniel' })
+ .expect(201)
+ .end(function (_err, res) {
+ // console.log(res.body)
+ res.body.should.have.property('name', 'Daniel')
+ done()
+ })
+ })
+ })
+
+ context('실패한 경우', function () {
+ it('name이 없으면 400 응답한다', function (done) {
+ request(app)
+ .post('/api/users').send({})
+ .expect(400)
+ .end(done)
+ })
+
+ it('name이 중복이면 409 응답한다', function (done) {
+ request(app)
+ .post('/api/users').send({ name: 'Alice' })
+ .expect(409)
+ .end(done)
+ })
+ })
+})
+
+describe('PUT /api/users', function () {
+ context('성공한 경우', function () {
+ it('204 응답한다', function (done) {
+ const id = 2
+ request(app)
+ .put(`/api/users/${id}`).send({ name: 'Joshua' })
+ .expect(204)
+ .end(done)
+ })
+ })
+
+ context('실패한 경우', function () {
+ it('id가 정수가 아니면 400 응답한다', function (done) {
+ request(app)
+ .put('/api/users/two')
+ .expect(404)
+ .end(done)
+ })
+ it('찾을 수 없는 id일 경우 404 응답한다', function (done) {
+ const id = 5
+ request(app)
+ .put(`/api/users/${id}`).send({ name: 'Joshua' })
+ .expect(404)
+ .end(done)
+ })
+ })
+})
+
+describe('DELETE /api/users/:id', function () {
+ context('성공한 경우', function () {
+ it('204 응답한다', function (done) {
+ const id = 1
+ request(app)
+ .delete(`/api/users/${id}`)
+ .expect(204)
+ .end(done)
+ })
+ })
+
+ context('실패한 경우', function () {
+ it('id가 정수가 아니면 400 응답한다', function (done) {
+ request(app)
+ .delete('/api/users/two')
+ .expect(400)
+ .end(done)
+ })
+ it('찾을 수 없는 id일 경우 404 응답한다', function (done) {
+ const id = 5
+ request(app)
+ .delete(`/api/users/${id}`)
+ .expect(404)
+ .end(done)
+ })
+ })
+})
diff --git a/api/ws/index.js b/api/ws/index.js
new file mode 100644
index 0000000..f06b3d4
--- /dev/null
+++ b/api/ws/index.js
@@ -0,0 +1,39 @@
+const logger = require('../../logger')
+const poll = require('poll').default
+
+let connections = []
+let polling = false
+
+function doPolling () {
+ logger.debug('doPolling')
+}
+
+function stopPolling () {
+ polling = (connections.length > 0)
+ return !polling
+}
+
+const connect = function (ws, req) {
+ connections.push(ws)
+ logger.info(`open: connections=${connections.length}`)
+
+ ws.on('message', function message (msg) {
+ logger.info(`message: msg=${msg}`)
+ connections.forEach(function (conn) {
+ conn.send(msg)
+ })
+ })
+
+ ws.on('close', function close () {
+ connections = connections.filter(function (conn) {
+ return conn !== ws
+ })
+ logger.info(`close: connections=${connections.length}`)
+ })
+
+ if (!polling) {
+ poll(doPolling, 1000, stopPolling)
+ }
+}
+
+module.exports = connect
diff --git a/apis.code-workspace b/apis.code-workspace
new file mode 100644
index 0000000..362d7c2
--- /dev/null
+++ b/apis.code-workspace
@@ -0,0 +1,7 @@
+{
+ "folders": [
+ {
+ "path": "."
+ }
+ ]
+}
\ No newline at end of file
diff --git a/bin/www.js b/bin/www.js
new file mode 100644
index 0000000..8967c39
--- /dev/null
+++ b/bin/www.js
@@ -0,0 +1,8 @@
+const app = require('../api/app')
+const config = require('config')
+const logger = require('../logger')
+
+const port = config.get('APP.port')
+app.listen(port, function () {
+ logger.info(`API server is running on ${port} [${process.env.NODE_ENV}]`)
+})
diff --git a/config/default.json b/config/default.json
new file mode 100644
index 0000000..cf79115
--- /dev/null
+++ b/config/default.json
@@ -0,0 +1,10 @@
+{
+ "APP": {
+ "port": 8080
+ },
+ "API": {
+ "users": {
+ "limit": 10
+ }
+ }
+}
diff --git a/config/production.json b/config/production.json
new file mode 100644
index 0000000..cf79115
--- /dev/null
+++ b/config/production.json
@@ -0,0 +1,10 @@
+{
+ "APP": {
+ "port": 8080
+ },
+ "API": {
+ "users": {
+ "limit": 10
+ }
+ }
+}
diff --git a/db/db.js b/db/db.js
new file mode 100644
index 0000000..fd6660e
--- /dev/null
+++ b/db/db.js
@@ -0,0 +1,4 @@
+const knex = require('../knexfile')
+const db = require('knex')(knex[process.env.NODE_ENV])
+
+module.exports = db
diff --git a/db/migrations/20190908180609_users.js b/db/migrations/20190908180609_users.js
new file mode 100644
index 0000000..e6dc477
--- /dev/null
+++ b/db/migrations/20190908180609_users.js
@@ -0,0 +1,14 @@
+exports.up = function (knex) {
+ return Promise.all([
+ knex.schema.createTable('users', (table) => {
+ table.increments('id').primary()
+ table.string('name').notNullable()
+ })
+ ])
+}
+
+exports.down = function (knex) {
+ return Promise.all([
+ knex.schema.dropTable('users')
+ ])
+}
diff --git a/db/seeds/development/users.js b/db/seeds/development/users.js
new file mode 100644
index 0000000..2a692bf
--- /dev/null
+++ b/db/seeds/development/users.js
@@ -0,0 +1,13 @@
+
+exports.seed = function (knex) {
+ // Deletes ALL existing entries
+ return knex('users').del()
+ .then(function () {
+ // Inserts seed entries
+ return knex('users').insert([
+ { id: 1, name: 'Alice' },
+ { id: 2, name: 'Bek' },
+ { id: 3, name: 'Chris' }
+ ])
+ })
+}
diff --git a/db/seeds/staging/users.js b/db/seeds/staging/users.js
new file mode 100644
index 0000000..2a692bf
--- /dev/null
+++ b/db/seeds/staging/users.js
@@ -0,0 +1,13 @@
+
+exports.seed = function (knex) {
+ // Deletes ALL existing entries
+ return knex('users').del()
+ .then(function () {
+ // Inserts seed entries
+ return knex('users').insert([
+ { id: 1, name: 'Alice' },
+ { id: 2, name: 'Bek' },
+ { id: 3, name: 'Chris' }
+ ])
+ })
+}
diff --git a/dev.sqlite3 b/dev.sqlite3
new file mode 100644
index 0000000..3512db0
Binary files /dev/null and b/dev.sqlite3 differ
diff --git a/knexfile.js b/knexfile.js
new file mode 100644
index 0000000..6eb85d2
--- /dev/null
+++ b/knexfile.js
@@ -0,0 +1,63 @@
+
+module.exports = {
+
+ development: {
+ client: 'sqlite3',
+ debug: true,
+ connection: {
+ filename: './dev.sqlite3'
+ },
+ migrations: {
+ directory: `${__dirname}/db/migrations`
+ },
+ seeds: {
+ directory: `${__dirname}/db/seeds/development`
+ },
+ useNullAsDefault: true
+ },
+
+ staging: {
+ client: 'mysql',
+ debug: true,
+ connection: {
+ host: '192.168.0.78',
+ user: 'root',
+ password: 'next.123',
+ database: 'test',
+ charset: 'utf8'
+ },
+ pool: {
+ min: 2,
+ max: 10
+ },
+ migrations: {
+ directory: `${__dirname}/db/migrations`
+ },
+ seeds: {
+ directory: `${__dirname}/db/seeds/staging`
+ }
+ },
+
+ production: {
+ client: 'mysql',
+ debug: true,
+ connection: {
+ host: '192.168.0.78',
+ user: 'root',
+ password: 'next.123',
+ database: 'test',
+ charset: 'utf8'
+ },
+ pool: {
+ min: 2,
+ max: 10
+ },
+ migrations: {
+ directory: `${__dirname}/db/migrations`
+ },
+ seeds: {
+ directory: `${__dirname}/db/seeds/production`
+ }
+ }
+
+}
diff --git a/logger.js b/logger.js
new file mode 100644
index 0000000..e8fb6f4
--- /dev/null
+++ b/logger.js
@@ -0,0 +1,54 @@
+const appRoot = require('app-root-path')
+const { createLogger, format, transports } = require('winston')
+const { combine, timestamp, label, printf } = format
+
+const apiFormat = printf(function ({ level, message, label, timestamp }) {
+ return `${timestamp} [${label}] ${level}: ${message}`
+})
+
+const options = {
+ file: {
+ level: 'info',
+ filename: `${appRoot}/log/app.log`,
+ handleExceptions: true,
+ json: false,
+ maxsize: 5242880,
+ maxFiles: 5,
+ colorize: false,
+ format: combine(
+ label({ label: 'apis' }),
+ timestamp(),
+ apiFormat
+ )
+ },
+ console: {
+ level: 'debug',
+ handleExceptions: true,
+ json: false,
+ colorize: true,
+ format: combine(
+ label({ label: 'apis' }),
+ timestamp(),
+ apiFormat
+ )
+ }
+}
+
+const logger = createLogger({
+ exitOnError: false,
+ transports: [
+ new transports.File(options.file)
+ ]
+})
+
+if (process.env.NODE_ENV !== 'production') {
+ logger.add(new transports.Console(options.console))
+}
+
+logger.stream = {
+ write: function (message, encoding) {
+ logger.info(message)
+ }
+}
+
+module.exports = logger
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..b671f58
--- /dev/null
+++ b/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "apis",
+ "version": "1.0.0",
+ "description": "API server (REST, WS)",
+ "scripts": {
+ "development": "cross-env NODE_ENV=development node ./bin/www.js",
+ "staging": "cross-env NODE_ENV=staging node ./bin/www.js",
+ "production": "cross-env NODE_ENV=production node ./bin/www.js",
+ "test-driven": "cross-env NODE_ENV=development mocha ./api/**/*.spec.js"
+ },
+ "author": "ilshookim",
+ "license": "MIT",
+ "dependencies": {
+ "app-root-path": "^2.2.1",
+ "body-parser": "^1.19.0",
+ "config": "^3.2.2",
+ "cross-env": "^5.2.1",
+ "express": "^4.17.1",
+ "express-ws": "^4.0.0",
+ "knex": "^0.19.3",
+ "morgan": "^1.9.1",
+ "mysql": "^2.17.1",
+ "poll": "^1.0.1",
+ "serve-static": "^1.14.1",
+ "sqlite3": "^4.1.0",
+ "winston": "^3.2.1"
+ },
+ "devDependencies": {
+ "mocha": "^6.2.0",
+ "should": "^13.2.3",
+ "standard": "^14.1.0",
+ "supertest": "^4.0.2"
+ },
+ "standard": {
+ "env": [
+ "mocha"
+ ]
+ }
+}