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" + ] + } +}