diff --git a/.gitignore b/.gitignore index 911f7f45e9..78a2bf4294 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store website/public/gen website/public/common +website/public/apidoc node_modules *.swp .idea* diff --git a/package.json b/package.json index fff411bb2a..8a3f1c23dd 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "habitrpg", "description": "A habit tracker app which treats your goals like a Role Playing Game.", - "version": "0.0.0-152", + "version": "3.0.0-alpha", "main": "./website/src/index.js", "dependencies": { "accepts": "^1.3.0", "amazon-payments": "0.0.4", "amplitude": "^2.0.3", + "apidoc": "^0.13.1", "async": "^1.5.0", "aws-sdk": "^2.0.25", "babel-core": "^5.8.34", @@ -119,6 +120,7 @@ "mongodb": "^2.0.46", "mongoskin": "~0.6.1", "nock": "^2.17.0", + "phantomjs": "^1.9.18", "protractor": "~2.5.1", "rewire": "^2.3.3", "rimraf": "^2.4.3", @@ -130,5 +132,12 @@ "uuid": "^2.0.1", "vinyl-source-stream": "^1.0.0", "vinyl-transform": "^1.0.0" + }, + "apidoc": { + "name": "habitica", + "title": "Habitica", + "version": "3.0.0", + "url": "https://habitica.com/api/v3", + "sampleUrl": "https://habitica.com/api/v3" } } diff --git a/tasks/gulp-apidoc.js b/tasks/gulp-apidoc.js new file mode 100644 index 0000000000..cb2777c254 --- /dev/null +++ b/tasks/gulp-apidoc.js @@ -0,0 +1,22 @@ +import gulp from 'gulp'; +import clean from 'rimraf'; +import apidoc from 'apidoc'; + +const APIDOC_DEST_PATH = './website/public/apidoc'; +const APIDOC_SRC_PATH = './website/src'; +gulp.task('apidoc:clean', (done) => { + clean(APIDOC_DEST_PATH, done); +}); + +gulp.task('apidoc', ['apidoc:clean'], (done) => { + let result = apidoc.createDoc({ + src: APIDOC_SRC_PATH, + dest: APIDOC_DEST_PATH, + }); + + if (result === false) { + done(new Error('There was a problem generating apiDoc documentation.')) + } else { + done(); + } +}); diff --git a/tasks/gulp-build.js b/tasks/gulp-build.js index 599d577b10..e7e683ebb9 100644 --- a/tasks/gulp-build.js +++ b/tasks/gulp-build.js @@ -17,6 +17,6 @@ gulp.task('build:dev:watch', ['build:dev'], () => { gulp.watch(['website/public/**/*.styl', 'common/script/*']); }); -gulp.task('build:prod', ['browserify', 'prepare:staticNewStuff'], (done) => { +gulp.task('build:prod', ['browserify', 'prepare:staticNewStuff', 'apidoc'], (done) => { gulp.start('grunt-build:prod', done); }); diff --git a/test/api/v3/unit/libs/errors.test.js b/test/api/v3/unit/libs/errors.test.js index e859d2bb02..15110a06ee 100644 --- a/test/api/v3/unit/libs/errors.test.js +++ b/test/api/v3/unit/libs/errors.test.js @@ -3,6 +3,7 @@ import { NotAuthorized, BadRequest, InternalServerError, + NotFound, } from '../../../../../website/src/libs/api-v3/errors'; describe('Custom Errors', () => { @@ -21,7 +22,7 @@ describe('Custom Errors', () => { expect(notAuthorizedError).to.be.an.instanceOf(CustomError); }); - it('it returns an http code of 400', () => { + it('it returns an http code of 401', () => { let notAuthorizedError = new NotAuthorized(); expect(notAuthorizedError.httpCode).to.eql(401); @@ -40,6 +41,32 @@ describe('Custom Errors', () => { }); }); + describe('NotFound', () => { + it('is an instance of CustomError', () => { + let notAuthorizedError = new NotFound(); + + expect(notAuthorizedError).to.be.an.instanceOf(CustomError); + }); + + it('it returns an http code of 404', () => { + let notAuthorizedError = new NotFound(); + + expect(notAuthorizedError.httpCode).to.eql(404); + }); + + it('returns a default message', () => { + let notAuthorizedError = new NotFound(); + + expect(notAuthorizedError.message).to.eql('Not found.'); + }); + + it('allows a custom message', () => { + let notAuthorizedError = new NotFound('Custom Error Message'); + + expect(notAuthorizedError.message).to.eql('Custom Error Message'); + }); + }); + describe('BadRequest', () => { it('is an instance of CustomError', () => { let badRequestError = new BadRequest(); @@ -82,7 +109,7 @@ describe('Custom Errors', () => { it('returns a default message', () => { let internalServerError = new InternalServerError(); - expect(internalServerError.message).to.eql('Internal server error.'); + expect(internalServerError.message).to.eql('An unexpected error occurred.'); }); it('allows a custom message', () => { diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 912dd75d1c..afc506e976 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -31,7 +31,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(500); expect(res.json).to.be.calledWith({ error: 'InternalServerError', - message: 'Internal server error.', + message: 'An unexpected error occurred.', }); }); @@ -63,7 +63,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(500); expect(res.json).to.be.calledWith({ error: 'InternalServerError', - message: 'Internal server error.', + message: 'An unexpected error occurred.', }); }); @@ -88,7 +88,7 @@ describe('errorHandler', () => { errorHandler(error, req, res, next); expect(logger.error).to.be.calledOnce; - expect(logger.error).to.be.calledWith(error.stack, { + expect(logger.error).to.be.calledWithExactly(error.stack, { originalUrl: req.originalUrl, headers: req.headers, body: req.body, diff --git a/test/api/v3/unit/middlewares/notFound.test.js b/test/api/v3/unit/middlewares/notFound.test.js new file mode 100644 index 0000000000..44ca295e95 --- /dev/null +++ b/test/api/v3/unit/middlewares/notFound.test.js @@ -0,0 +1,14 @@ +import { requester } from '../../../../helpers/api-integration.helper'; + +describe('notFound Middleware', () => { + it('returns a 404 error when the resource is not found', () => { + let request = requester().get('/api/v3/dummy-url'); + + return request.then((errBody) => { + expect(errBody.error).to.equal('NotFound'); + expect(errBody.message).to.equal('Not found.'); + }).to.eventually.be.rejected.and.eql({ + code: 404, + }); + }); +}); diff --git a/website/src/controllers/api-v3/example.js b/website/src/controllers/api-v3/example.js index f5e9f24b71..6ad474a5f2 100644 --- a/website/src/controllers/api-v3/example.js +++ b/website/src/controllers/api-v3/example.js @@ -1,15 +1,34 @@ -// An example file to show how a controller should be structured let api = {}; +/** + * @api {get} /example/:id Request Example information + * @apiVersion 3.0.0 + * @apiName GetExample + * @apiGroup Example + * + * @apiParam {Number} id Examples unique ID. + * + * @apiSuccess {String} firstname Firstname of the Example. + * @apiSuccess {String} lastname Lastname of the Example. + * + * @apiSuccessExample Success-Response: + * HTTP/1.1 200 OK + * { + * "firstname": "John", + * "lastname": "Doe" + * } + * + * @apiUse NotFound + */ api.exampleRoute = { method: 'GET', - url: '/example/:param', + url: '/example/:id', middlewares: [], handler (req, res) { res.status(200).send({ - status: 'ok', + status: req.params.id, }); }, }; -export default api; \ No newline at end of file +export default api; diff --git a/website/src/index.js b/website/src/index.js index 2440dde36f..a562dfa9c2 100644 --- a/website/src/index.js +++ b/website/src/index.js @@ -14,8 +14,6 @@ var IS_PROD = nconf.get('IS_PROD'); var IS_DEV = nconf.get('IS_DEV'); var cores = Number(nconf.get('WEB_CONCURRENCY')) || 0; -if (IS_DEV) Error.stackTraceLimit = Infinity; - // Setup the cluster module if (cores !== 0 && cluster.isMaster && (IS_DEV || IS_PROD)) { // Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production) diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js index 1439669d20..89e2bd4fd1 100644 --- a/website/src/libs/api-v3/errors.js +++ b/website/src/libs/api-v3/errors.js @@ -7,8 +7,17 @@ export class CustomError extends Error { } } -// NotAuthorized error with a 401 http error code -// used when a request is not authorized +/** + * @apiDefine NotFound + * @apiError NotFound The client is not authorized to make this request. + * + * @apiErrorExample Error-Response: + * HTTP/1.1 401 Unauthorized + * { + * "error": "NotAuthorized", + * "message": "Not authorized." + * } + */ export class NotAuthorized extends CustomError { constructor (customMessage) { super(); @@ -18,9 +27,17 @@ export class NotAuthorized extends CustomError { } } -// BadRequest error with a 400 http error code -// used for requests not formatted correctly -// TODO use for validation errors too? +/** + * @apiDefine BadRequest + * @apiError BadRequest The request wasn't formatted correctly. + * + * @apiErrorExample Error-Response: + * HTTP/1.1 400 Bad Request + * { + * "error": "BadRequest", + * "message": "Bad request." + * } + */ export class BadRequest extends CustomError { constructor (customMessage) { super(); @@ -30,13 +47,42 @@ export class BadRequest extends CustomError { } } -// InternalError error with a 500 http error code -// used when an unexpected, internal server error is thrown +/** + * @apiDefine NotFound + * @apiError NotFound The requested resource was not found. + * + * @apiErrorExample Error-Response: + * HTTP/1.1 404 Not Found + * { + * "error": "NotFound", + * "message": "Not found." + * } + */ +export class NotFound extends CustomError { + constructor (customMessage) { + super(); + this.name = this.constructor.name; + this.httpCode = 404; + this.message = customMessage || 'Not found.'; + } +} + +/** + * @apiDefine InternalServerError + * @apiError InternalServerError An unexpected error occurred. + * + * @apiErrorExample Error-Response: + * HTTP/1.1 500 Internal Server Error + * { + * "error": "InternalServerError", + * "message": "An unexpected error occurred." + * } + */ export class InternalServerError extends CustomError { constructor (customMessage) { super(); this.name = this.constructor.name; this.httpCode = 500; - this.message = customMessage || 'Internal server error.'; + this.message = customMessage || 'An unexpected error occurred.'; } } diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index 3ab20ad685..0d00ca7f4b 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -14,6 +14,7 @@ if (IS_PROD) { logger .add(winston.transports.Console, { colorize: true, + prettyPrint: true, }); } diff --git a/website/src/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js index 4bce7b1e26..8c346577bc 100644 --- a/website/src/libs/api-v3/setupRoutes.js +++ b/website/src/libs/api-v3/setupRoutes.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import express from 'express'; import _ from 'lodash'; + const CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); let router = express.Router(); // eslint-disable-line new-cap @@ -20,4 +21,4 @@ fs }); }); -export default router; \ No newline at end of file +export default router; diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 39847e67bf..dfd19e5493 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -4,9 +4,16 @@ import analytics from './analytics'; import errorHandler from './errorHandler'; import bodyParser from 'body-parser'; import routes from '../../libs/api-v3/setupRoutes'; +import notFoundHandler from './notFound'; +import nconf from 'nconf'; +import morgan from 'morgan'; + +const IS_PROD = nconf.get('IS_PROD'); +const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); export default function attachMiddlewares (app) { - // Parse query parameters and json bodies + if (!IS_PROD && !DISABLE_LOGGING) app.use(morgan('dev')); + // TODO handle errors app.use(bodyParser.urlencoded({ extended: true, // Uses 'qs' library as old connect middleware @@ -14,7 +21,8 @@ export default function attachMiddlewares (app) { app.use(bodyParser.json()); app.use(analytics); - app.use(routes); + app.use('/api/v3', routes); + app.use(notFoundHandler); // Error handler middleware, define as the last one app.use(errorHandler); diff --git a/website/src/middlewares/api-v3/notFound.js b/website/src/middlewares/api-v3/notFound.js new file mode 100644 index 0000000000..733a247d1d --- /dev/null +++ b/website/src/middlewares/api-v3/notFound.js @@ -0,0 +1,7 @@ +import { + NotFound, +} from '../../libs/api-v3/errors'; + +export default function (req, res, next) { + next(new NotFound()); +} diff --git a/website/src/middlewares/api-v3/static.js b/website/src/middlewares/api-v3/static.js new file mode 100644 index 0000000000..f944e1e301 --- /dev/null +++ b/website/src/middlewares/api-v3/static.js @@ -0,0 +1,18 @@ +import express from 'express'; +import nconf from 'nconf'; +import path from 'path'; + +const IS_PROD = nconf.get('IS_PROD'); +const MAX_AGE = IS_PROD ? 31536000000 : 0; +const PUBLIC_DIR = path.join(__dirname, '/../../../public'); +const BUILD_DIR = path.join(__dirname, '/../../../build'); + +export default function staticMiddleware (expressApp) { + // TODO move all static files to a single location (one for public and one for build) + expressApp.use(express.static(BUILD_DIR, { maxAge: MAX_AGE })); + expressApp.use('/common/dist', express.static(`${PUBLIC_DIR}/../../common/dist`, { maxAge: MAX_AGE })); + expressApp.use('/common/audio', express.static(`${PUBLIC_DIR}/../../common/audio`, { maxAge: MAX_AGE })); + expressApp.use('/common/script/public', express.static(`${PUBLIC_DIR}/../../common/script/public`, { maxAge: MAX_AGE })); + expressApp.use('/common/img', express.static(`${PUBLIC_DIR}/../../common/img`, { maxAge: MAX_AGE })); + expressApp.use(express.static(PUBLIC_DIR)); +} diff --git a/website/src/server.js b/website/src/server.js index 1142f27539..aa0013d89e 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -14,6 +14,7 @@ import mongoose from 'mongoose'; import Q from 'q'; import domainMiddleware from './middlewares/api-v3/domain'; import attachMiddlewares from './middlewares/api-v3/index'; +import staticMiddleware from './middlewares/api-v3/static'; // Setup translations // let i18n = require('./libs/api-v2/i18n'); @@ -88,7 +89,7 @@ app.use(domainMiddleware(server, mongoose)); // Matches all request except the ones going to /api/v3/** app.all(/^(?!\/api\/v3).+/i, oldApp); // Matches all requests going to /api/v3 -app.all('/api/v3', newApp); +app.all('/api/*', newApp); // Mount middlewares for the new app attachMiddlewares(newApp); @@ -144,18 +145,22 @@ oldApp.use('/api/v1', require('./routes/api-v1')); oldApp.use('/export', require('./routes/dataexport')); require('./routes/api-v2/swagger')(swagger, v2); -var maxAge = IS_PROD ? 31536000000 : 0; // Cache emojis without copying them to build, they are too many -oldApp.use(express['static'](path.join(__dirname, "/../build"), { maxAge: maxAge })); -oldApp.use('/common/dist', express['static'](publicDir + "/../../common/dist", { maxAge: maxAge })); -oldApp.use('/common/audio', express['static'](publicDir + "/../../common/audio", { maxAge: maxAge })); -oldApp.use('/common/script/public', express['static'](publicDir + "/../../common/script/public", { maxAge: maxAge })); -oldApp.use('/common/img', express['static'](publicDir + "/../../common/img", { maxAge: maxAge })); -oldApp.use(express['static'](publicDir)); oldApp.use(require('./middlewares/api-v2/errorHandler')); +* +let maxAge = IS_PROD ? 31536000000 : 0; + +oldApp.use(express.static(path.join(__dirname, '/../build'), { maxAge })); +oldApp.use('/common/dist', express.static(`${publicDir}/../../common/dist`, { maxAge })); +oldApp.use('/common/audio', express.static(`${publicDir}/../../common/audio`, { maxAge })); +oldApp.use('/common/script/public', express.static(`${publicDir}/../../common/script/public`, { maxAge })); +oldApp.use('/common/img', express.static(`${publicDir}/../../common/img`, { maxAge })); +oldApp.use(express.static(publicDir)); */ +staticMiddleware(app); + server.on('request', app); server.listen(app.get('port'), () => { return logger.info(`Express server listening on port ${app.get('port')}`);