Merge pull request #522 from switz/api

@lefnire => Initial API support including specs
This commit is contained in:
Tyler Renelle 2013-03-01 12:32:49 -08:00
commit 7e78e8cd12
11 changed files with 490 additions and 109 deletions

View file

@ -23,8 +23,10 @@
"mongoskin": "*", "mongoskin": "*",
"nconf": "*", "nconf": "*",
"icalendar": "git://github.com/lefnire/node-icalendar#master", "icalendar": "git://github.com/lefnire/node-icalendar#master",
"superagent": "~0.12.4",
"resolve": "~0.2.3", "resolve": "~0.2.3",
"browserify": "1.17.3", "browserify": "1.17.3",
"expect.js": "~0.2.0",
"webkit-devtools-agent": "*" "webkit-devtools-agent": "*"
}, },
"private": true, "private": true,
@ -38,6 +40,7 @@
"npm": "1.1.x" "npm": "1.1.x"
}, },
"scripts": { "scripts": {
"start": "server.js" "start": "server.js",
"test": "mocha test/api.mocha.coffee"
} }
} }

View file

@ -3,56 +3,116 @@ router = new express.Router()
scoring = require '../app/scoring' scoring = require '../app/scoring'
_ = require 'underscore' _ = require 'underscore'
icalendar = require('icalendar') validator = require 'derby-auth/node_modules/validator'
check = validator.check
sanitize = validator.sanitize
icalendar = require 'icalendar'
# ---------- /v1 API ------------ NO_TOKEN_OR_UID = err: "You must include a token and uid (user id) in your request"
# Every url added beneath router is prefaced by /v1 NO_USER_FOUND = err: "No user found."
# ---------- /api/v1 API ------------
# Every url added beneath router is prefaced by /api/v1
### ###
v1 API. Requires user-id and apiToken, task-id, direction. Test with: v1 API. Requires api-v1-user (user id) and api-v1-key (api key) headers, Test with:
curl -X POST -H "Content-Type:application/json" -d '{"apiToken":"{TOKEN}"}' localhost:3000/v1/users/{UID}/tasks/productivity/up $ cd node_modules/racer && npm install && cd ../..
$ mocha test/api.mocha.coffee
### ###
router.post '/users/:uid/tasks/:taskId/:direction', (req, res) -> router.get '/status', (req, res) ->
{uid, taskId, direction} = req.params res.json status: 'up'
{apiToken, title, service, icon} = req.body
console.log {params:req.params, body:req.body} if process.env.NODE_ENV == 'development'
# Send error responses for improper API call auth = (req, res, next) ->
return res.send(500, 'request body "apiToken" required') unless apiToken uid = req.headers['x-api-user']
return res.send(500, ':uid required') unless uid token = req.headers['x-api-key']
return res.send(500, ':taskId required') unless taskId return res.json 401, NO_TOKEN_OR_UID unless uid || token
return res.send(500, ":direction must be 'up' or 'down'") unless direction in ['up','down']
req._isServer = true
model = req.getModel() model = req.getModel()
model.fetch model.query('users').withIdAndToken(uid, apiToken), (err, result) -> query = model.query('users').withIdAndToken(uid, token)
return res.send(500, err) if err
user = result
userObj = user.get()
if _.isEmpty(userObj)
return res.send(500, "User with uid=#{uid}, token=#{apiToken} not found. Make sure you're not using your username, but your User Id")
model.ref('_user', user) query.fetch (err, user) ->
return res.json err: err if err
req.user = user
req.userObj = user.get()
return res.json 401, NO_USER_FOUND if !req.userObj || _.isEmpty(req.userObj)
req._isServer = true
next()
# Create task if doesn't exist router.get '/user', auth, (req, res) ->
# TODO add service & icon to task user = req.userObj
unless model.get("_user.tasks.#{taskId}")
model.refList "_habitList", "_user.tasks", "_user.habitIds"
model.at('_habitList').push
id: taskId
type: 'habit'
text: (title || taskId)
value: 0
up: true
down: true
notes: "This task was created by a third-party service. Feel free to edit, it won't harm the connection to that service. Additionally, multiple services may piggy-back off this task."
score = scoring.Scoring(model) delete user.apiToken
delta = score.score(taskId, direction)
result = model.get ('_user.stats') res.json user
result.delta = delta
res.send(result) router.get '/user/task/:id', auth, (req, res) ->
task = req.userObj.tasks[req.params.id]
return res.json 400, err: "No task found." if !task || _.isEmpty(task)
res.json 200, task
validateTask = (req, res, next) ->
task = {}
newTask = { type, text, notes, value, up, down, completed } = req.body
# If we're updating, get the task from the user
if req.method is 'PUT'
task = req.userObj?.tasks[req.params.id]
return res.json 400, err: "No task found." if !task || _.isEmpty(task)
# Strip for now
type = undefined
delete newTask.type
else if req.method is 'POST'
unless /^(habit|todo|daily|reward)$/.test type
return res.json 400, err: 'type must be habit, todo, daily, or reward'
text = sanitize(text).xss()
notes = sanitize(notes).xss()
value = sanitize(value).toInt()
switch type
when 'habit'
newTask.up = true unless typeof up is 'boolean'
newTask.down = true unless typeof down is 'boolean'
when 'daily', 'todo'
newTask.completed = false unless typeof completed is 'boolean'
_.extend task, newTask
req.task = task
next()
router.put '/user/task/:id', auth, validateTask, (req, res) ->
req.user.set "tasks.#{req.task.id}", req.task
res.json 200, req.task
router.post '/user/task', auth, validateTask, (req, res) ->
task = req.task
type = task.type
model = req.getModel()
model.ref '_user', req.user
model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids"
model.at("_#{type}List").push task
res.json 201, task
router.get '/user/tasks', auth, (req, res) ->
user = req.userObj
return res.json 400, NO_USER_FOUND if !user || _.isEmpty(user)
model = req.getModel()
model.ref '_user', req.user
tasks = []
types = ['habit','todo','daily','reward']
if /^(habit|todo|daily|reward)$/.test req.query.type
types = [req.query.type]
for type in types
model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids"
tasks = tasks.concat model.get("_#{type}List")
res.json 200, tasks
router.get '/users/:uid/calendar.ics', (req, res) -> router.get '/users/:uid/calendar.ics', (req, res) ->
#return next() #disable for now #return next() #disable for now
@ -62,11 +122,11 @@ router.get '/users/:uid/calendar.ics', (req, res) ->
model = req.getModel() model = req.getModel()
query = model.query('users').withIdAndToken(uid, apiToken) query = model.query('users').withIdAndToken(uid, apiToken)
query.fetch (err, result) -> query.fetch (err, result) ->
return res.send(500, err) if err return res.send(400, err) if err
tasks = result.get('tasks') tasks = result.get('tasks')
# tasks = result[0].tasks # tasks = result[0].tasks
tasksWithDates = _.filter tasks, (task) -> !!task.date tasksWithDates = _.filter tasks, (task) -> !!task.date
return res.send(500, "No events found") if _.isEmpty(tasksWithDates) return res.send(400, "No events found") if _.isEmpty(tasksWithDates)
ical = new icalendar.iCalendar() ical = new icalendar.iCalendar()
ical.addProperty('NAME', 'HabitRPG') ical.addProperty('NAME', 'HabitRPG')

View file

@ -1,6 +1,10 @@
express = require 'express' express = require 'express'
router = new express.Router() router = new express.Router()
scoring = require '../app/scoring'
_ = require 'underscore'
icalendar = require('icalendar')
# ---------- Deprecated Paths ------------ # ---------- Deprecated Paths ------------
deprecatedMessage = 'This API is no longer supported, see https://github.com/lefnire/habitrpg/wiki/API for new protocol' deprecatedMessage = 'This API is no longer supported, see https://github.com/lefnire/habitrpg/wiki/API for new protocol'
@ -9,4 +13,72 @@ router.get '/:uid/up/:score?', (req, res) -> res.send(500, deprecatedMessage)
router.get '/:uid/down/:score?', (req, res) -> res.send(500, deprecatedMessage) router.get '/:uid/down/:score?', (req, res) -> res.send(500, deprecatedMessage)
router.post '/users/:uid/tasks/:taskId/:direction', (req, res) -> res.send(500, deprecatedMessage) router.post '/users/:uid/tasks/:taskId/:direction', (req, res) -> res.send(500, deprecatedMessage)
router.post '/v1/users/:uid/tasks/:taskId/:direction', (req, res) ->
{uid, taskId, direction} = req.params
{apiToken, title, service, icon} = req.body
console.log {params:req.params, body:req.body} if process.env.NODE_ENV == 'development'
# Send error responses for improper API call
return res.send(500, 'request body "apiToken" required') unless apiToken
return res.send(500, ':uid required') unless uid
return res.send(500, ':taskId required') unless taskId
return res.send(500, ":direction must be 'up' or 'down'") unless direction in ['up','down']
model = req.getModel()
model.fetch model.query('users').withIdAndToken(uid, apiToken), (err, result) ->
return res.send(500, err) if err
user = result
userObj = user.get()
if _.isEmpty(userObj)
return res.send(500, "User with uid=#{uid}, token=#{apiToken} not found. Make sure you're not using your username, but your User Id")
model.ref('_user', user)
req._isServer = true
# Create task if doesn't exist
# TODO add service & icon to task
unless model.get("_user.tasks.#{taskId}")
model.refList "_habitList", "_user.tasks", "_user.habitIds"
model.at('_habitList').push
id: taskId
type: 'habit'
text: (title || taskId)
value: 0
up: true
down: true
notes: "This task was created by a third-party service. Feel free to edit, it won't harm the connection to that service. Additionally, multiple services may piggy-back off this task."
score = new scoring.Scoring(model)
delta = score.score(taskId, direction)
result = model.get ('_user.stats')
result.delta = delta
res.send(result)
router.get '/v1/users/:uid/calendar.ics', (req, res) ->
#return next() #disable for now
{uid} = req.params
{apiToken} = req.query
model = req.getModel()
query = model.query('users').withIdAndToken(uid, apiToken)
query.fetch (err, result) ->
return res.send(500, err) if err
tasks = result.get('tasks')
# tasks = result[0].tasks
tasksWithDates = _.filter tasks, (task) -> !!task.date
return res.send(500, "No events found") if _.isEmpty(tasksWithDates)
ical = new icalendar.iCalendar()
ical.addProperty('NAME', 'HabitRPG')
_.each tasksWithDates, (task) ->
event = new icalendar.VEvent(task.id);
event.setSummary(task.text);
d = new Date(task.date)
d.date_only = true
event.setDate d
ical.addComponent event
res.type('text/calendar')
formattedIcal = ical.toString().replace(/DTSTART\:/g, 'DTSTART;VALUE=DATE:')
res.send(200, formattedIcal)
module.exports = router module.exports = router

View file

@ -31,7 +31,7 @@ server = http.createServer expressApp
module.exports = server module.exports = server
derby.use require('racer-db-mongo') derby.use require('racer-db-mongo')
store = derby.createStore module.exports.habitStore = store = derby.createStore
db: {type: 'Mongo', uri: process.env.NODE_DB_URI, safe:true} db: {type: 'Mongo', uri: process.env.NODE_DB_URI, safe:true}
listen: server listen: server
@ -74,6 +74,9 @@ mongo_store = new MongoStore {url: process.env.NODE_DB_URI}, ->
) )
# Adds req.getModel method # Adds req.getModel method
.use(store.modelMiddleware()) .use(store.modelMiddleware())
# API should be hit before all other routes
.use('/api/v1', require('./api').middleware)
.use(require('./deprecated').middleware)
# Show splash page for newcomers # Show splash page for newcomers
.use(middleware.splash) .use(middleware.splash)
.use(priv.middleware) .use(priv.middleware)
@ -81,9 +84,7 @@ mongo_store = new MongoStore {url: process.env.NODE_DB_URI}, ->
.use(auth.middleware(strategies, options)) .use(auth.middleware(strategies, options))
# Creates an express middleware from the app's routes # Creates an express middleware from the app's routes
.use(app.router()) .use(app.router())
.use('/v1', require('./api').middleware)
.use(require('./static').middleware) .use(require('./static').middleware)
.use(require('./deprecated').middleware)
.use(expressApp.router) .use(expressApp.router)
.use(serverError(root)) .use(serverError(root))

View file

@ -1,7 +1,4 @@
module.exports.splash = (req, res, next) -> module.exports.splash = (req, res, next) ->
# This was an API call, not a page load
return next() if req.is('json')
unless req.query?.play? or req.getModel().get('_userId') unless req.query?.play? or req.getModel().get('_userId')
res.redirect('/splash.html') res.redirect('/splash.html')
else else

View file

@ -28,7 +28,10 @@ userAccess = (store) ->
store.writeAccess "*", "users.*", -> # captures, value, accept, err -> store.writeAccess "*", "users.*", -> # captures, value, accept, err ->
accept = arguments[arguments.length-2] accept = arguments[arguments.length-2]
err = arguments[arguments.length - 1] err = arguments[arguments.length - 1]
# return err(derbyAuth.SESSION_INVALIDATED_ERROR) if derbyAuth.bustedSession(@) # return err(derbyAuth.SESSION_INVALIDATED_ERROR) if derbyAuth.bustedSession(@)
return accept(true) if derbyAuth.isServer(@)
return accept(false) if derbyAuth.bustedSession(@) return accept(false) if derbyAuth.bustedSession(@)
captures = arguments[0].split('.') captures = arguments[0].split('.')
@ -40,8 +43,7 @@ userAccess = (store) ->
return accept(true) return accept(true)
# Same session (user.id = this.session.userId) # Same session (user.id = this.session.userId)
if (uid is @session.userId) or derbyAuth.isServer(@) return accept(true) if uid is @session.userId
return accept(true)
accept(false) accept(false)

245
test/api.mocha.coffee Normal file
View file

@ -0,0 +1,245 @@
_ = require 'underscore'
request = require 'superagent'
expect = require 'expect.js'
require 'coffee-script'
conf = require("nconf")
conf.argv().env().file({file: __dirname + '../config.json'}).defaults
# Override normal ENV values with nconf ENV values (ENV values are used the same way without nconf)
#FIXME can't get nconf file above to load...
process.env.BASE_URL = conf.get("BASE_URL")
process.env.FACEBOOK_KEY = conf.get("FACEBOOK_KEY")
process.env.FACEBOOK_SECRET = conf.get("FACEBOOK_SECRET")
process.env.NODE_DB_URI = 'mongodb://localhost/habirpg'
## monkey-patch expect.js for better diffs on mocha
## see: https://github.com/LearnBoost/expect.js/pull/34
origBe = expect.Assertion::be
expect.Assertion::be = expect.Assertion::equal = (obj) ->
@_expected = obj
origBe.call this, obj
# Custom modules
character = require '../src/app/character'
###### Helpers & Variables ######
model = null
uuid = null
taskPath = null
baseURL = 'http://localhost:1337/api/v1'
###### Specs ######
describe 'API', ->
server = null
store = null
model = null
user = null
uid = null
before (done) ->
server = require '../src/server'
server.listen '1337', '0.0.0.0'
server.on 'listening', (data) ->
store = server.habitStore
#store.flush()
model = store.createModel()
model.set '_userId', uid = model.id()
user = character.newUserObject()
user.apiToken = model.id()
model.session = {userId:uid}
model.set "users.#{uid}", user
delete model.session
# Crappy hack to let server start before tests run
setTimeout done, 2000
describe 'Without token or user id', ->
it '/api/v1/status', (done) ->
request.get("#{baseURL}/status")
.set('Accept', 'application/json')
.end (res) ->
expect(res.statusCode).to.be 200
expect(res.body.status).to.be 'up'
done()
it '/api/v1/user', (done) ->
request.get("#{baseURL}/user")
.set('Accept', 'application/json')
.end (res) ->
expect(res.statusCode).to.be 401
expect(res.body.err).to.be 'You must include a token and uid (user id) in your request'
done()
describe 'With token and user id', ->
params = null
currentUser = null
before ->
user = model.at("users.#{uid}")
currentUser = user.get()
params =
title: 'Title'
text: 'Text'
type: 'habit'
beforeEach ->
currentUser = user.get()
it 'GET /api/v1/user', (done) ->
request.get("#{baseURL}/user")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.end (res) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 200
expect(res.body.id).not.to.be.empty()
self = _.clone(currentUser)
delete self.apiToken
expect(res.body).to.eql self
done()
it 'GET /api/v1/user/task/:id', (done) ->
tid = _.pluck(currentUser.tasks, 'id')[0]
request.get("#{baseURL}/user/task/#{tid}")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.end (res) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 200
expect(res.body).to.eql currentUser.tasks[tid]
done()
it 'POST /api/v1/user/task', (done) ->
request.post("#{baseURL}/user/task")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.send(params)
.end (res) ->
query = model.query('users').withIdAndToken(currentUser.id, currentUser.apiToken)
query.fetch (err, user) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 201
expect(res.body.id).not.to.be.empty()
# Ensure that user owns the newly created object
expect(user.get().tasks[res.body.id]).to.be.an('object')
done()
it 'POST /api/v1/user/task (without type)', (done) ->
request.post("#{baseURL}/user/task")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.send({})
.end (res) ->
expect(res.body.err).to.be 'type must be habit, todo, daily, or reward'
expect(res.statusCode).to.be 400
done()
it 'POST /api/v1/user/task (only type)', (done) ->
request.post("#{baseURL}/user/task")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.send(type: 'habit')
.end (res) ->
query = model.query('users').withIdAndToken(currentUser.id, currentUser.apiToken)
query.fetch (err, user) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 201
expect(res.body.id).not.to.be.empty()
# Ensure that user owns the newly created object
expect(user.get().tasks[res.body.id]).to.be.an('object')
done()
it 'PUT /api/v1/user/task/:id', (done) ->
tid = _.pluck(currentUser.tasks, 'id')[0]
request.put("#{baseURL}/user/task/#{tid}")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.send(text: 'bye')
.end (res) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 200
currentUser.tasks[tid].text = 'bye'
expect(res.body).to.eql currentUser.tasks[tid]
done()
it 'PUT /api/v1/user/task/:id (shouldnt update type)', (done) ->
tid = _.pluck(currentUser.tasks, 'id')[1]
type = if currentUser.tasks[tid].type is 'habit' then 'daily' else 'habit'
request.put("#{baseURL}/user/task/#{tid}")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.send(type: type, text: 'fishman')
.end (res) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 200
currentUser.tasks[tid].text = 'fishman'
expect(res.body).to.eql currentUser.tasks[tid]
done()
it 'PUT /api/v1/user/task/:id (update notes)', (done) ->
tid = _.pluck(currentUser.tasks, 'id')[2]
request.put("#{baseURL}/user/task/#{tid}")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.send(text: 'hi',notes:'foobar matey')
.end (res) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 200
currentUser.tasks[tid].text = 'hi'
currentUser.tasks[tid].notes = 'foobar matey'
expect(res.body).to.eql currentUser.tasks[tid]
done()
it 'GET /api/v1/user/tasks', (done) ->
request.get("#{baseURL}/user/tasks")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.end (res) ->
query = model.query('users').withIdAndToken(currentUser.id, currentUser.apiToken)
query.fetch (err, user) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 200
model.ref '_user', user
tasks = []
for type in ['habit','todo','daily','reward']
model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids"
tasks = tasks.concat model.get("_#{type}List")
# Ensure that user owns the tasks
expect(res.body.length).to.equal tasks.length
# Ensure that the two sets are equal
expect(_.difference(_.pluck(res.body,'id'), _.pluck(tasks,'id')).length).to.equal 0
done()
it 'GET /api/v1/user/tasks (todos)', (done) ->
request.get("#{baseURL}/user/tasks")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.query(type:'todo')
.end (res) ->
query = model.query('users').withIdAndToken(currentUser.id, currentUser.apiToken)
query.fetch (err, user) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 200
model.ref '_user', user
model.refList "_todoList", "_user.tasks", "_user.todoIds"
tasks = model.get("_todoList")
# Ensure that user owns the tasks
expect(res.body.length).to.equal tasks.length
# Ensure that the two sets are equal
expect(_.difference(_.pluck(res.body,'id'), _.pluck(tasks,'id')).length).to.equal 0
done()

View file

@ -12,8 +12,8 @@ casper.start "#{url}/?play=1", ->
@fill 'form#derby-auth-register', @fill 'form#derby-auth-register',
username: user1.id username: user1.id
email: "{user1.id}@gmail.com" email: "{user1.id}@gmail.com"
'email-confirmation': "{user1.id}@gmail.com"
password: 'habitrpg123' password: 'habitrpg123'
'password-confirmation': "habitrpg123"
, true , true
casper.thenOpen "#{url}/logout" casper.thenOpen "#{url}/logout"
casper.thenOpen "#{url}/?play=1", -> casper.thenOpen "#{url}/?play=1", ->

View file

@ -1,6 +1,7 @@
--colors --colors
--reporter spec --reporter spec
--timeout 1200 --timeout 2800
--ignore-leaks
--growl --growl
--debug --debug
--compilers coffee:coffee-script --compilers coffee:coffee-script