api: export all of api to functions, called by route. This way we can have our /batch-update route access the api functions

This commit is contained in:
Tyler Renelle 2013-08-10 23:29:24 -04:00
parent 3263708c9d
commit d17bd6fe90
5 changed files with 276 additions and 232 deletions

View file

@ -5,7 +5,7 @@ helpers = require('habitrpg-shared/script/helpers')
module.exports.batchTxn = batchTxn = (model, cb, options) ->
user = options?.user or model.at("_user")
uObj = hydrate(user.get()) # see https://github.com/codeparty/racer/issues/116
uObj = helpers.hydrate(user.get()) # see https://github.com/codeparty/racer/issues/116
batch =
set: (k,v) -> helpers.dotSet(k,v,uObj); paths[k] = true
get: (k) -> helpers.dotGet(k,uObj)
@ -87,18 +87,6 @@ module.exports.score = (model, taskId, direction, allowUndo=false) ->
delta
###
Make sure model.get() returns all properties, see https://github.com/codeparty/racer/issues/116
###
module.exports.hydrate = hydrate = (spec) ->
if _.isObject(spec) and !_.isArray(spec)
hydrated = {}
keys = _.keys(spec).concat(_.keys(spec.__proto__))
keys.forEach (k) -> hydrated[k] = hydrate(spec[k])
hydrated
else spec
###
Cleanup task-corruption (null tasks, rogue/invisible tasks, etc)
Obviously none of this should be happening, but we'll stop-gap until we can find & fix

View file

@ -1,7 +1,7 @@
express = require 'express'
router = new express.Router()
# @see ./routes.coffee for routing
_ = require 'lodash'
async = require 'async'
algos = require 'habitrpg-shared/script/algos'
helpers = require 'habitrpg-shared/script/helpers'
validator = require 'derby-auth/node_modules/validator'
@ -10,9 +10,42 @@ sanitize = validator.sanitize
utils = require 'derby-auth/utils'
misc = require '../app/misc'
api = module.exports
###
------------------------------------------------------------------------
Misc
------------------------------------------------------------------------
####
NO_TOKEN_OR_UID = err: "You must include a token and uid (user id) in your request"
NO_USER_FOUND = err: "No user found."
###
beforeEach auth interceptor
###
api.auth = (req, res, next) ->
uid = req.headers['x-api-user']
token = req.headers['x-api-key']
return res.json 401, NO_TOKEN_OR_UID unless uid || token
model = req.getModel()
query = model.query('users').withIdAndToken(uid, token)
query.fetch (err, user) ->
return res.json err: err if err
(req.habit ?= {}).user = user
req.habit.userObj = user.get()
return res.json 401, NO_USER_FOUND if _.isEmpty(req.habit.userObj)
req._isServer = true
next()
###
------------------------------------------------------------------------
Tasks
------------------------------------------------------------------------
###
addTask = (user, task, cb) ->
task.type ?= 'habit'
tid = user.add "tasks", task, ->
@ -33,20 +66,21 @@ score = (model, user, taskId, direction, cb) ->
###
This is called form deprecated.coffee's score function, and the req.headers are setup properly to handle the login
Export it also so we can call it from deprecated.coffee
###
scoreTask = (req, res, next) ->
{taskId, direction} = req.params
api.scoreTask = (req, res, next) ->
{id, direction} = req.params
{title, service, icon, type} = req.body
type ||= 'habit'
# Send error responses for improper API call
return res.send(500, ':taskId required') unless taskId
return res.send(500, ":direction must be 'up' or 'down'") unless direction in ['up','down']
return res.json 500, {err: ':id required'} unless id
return res.json 500, {err: ":direction must be 'up' or 'down'"} unless direction in ['up','down']
model = req.getModel()
{user, userObj} = req
{user, userObj} = req.habit
existingTask = user.at "tasks.#{taskId}"
existingTask = user.at "tasks.#{id}"
# TODO add service & icon to task
# If task exists, set it's compltion
if existingTask.get()
@ -54,9 +88,9 @@ scoreTask = (req, res, next) ->
existingTask.set 'completed', (direction is 'up') if /^(daily|todo)$/.test existingTask.get('type')
else
task =
id: taskId
id: id
type: type
text: (title || taskId)
text: (title || id)
value: 0
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."
@ -70,129 +104,42 @@ scoreTask = (req, res, next) ->
addTask user, task
# TODO - could modify batchTxn to conform to this better
delta = score model, user, taskId, direction, ->
delta = score model, user, id, direction, ->
result = user.get('stats')
result.delta = delta
res.json result
# ---------- /api/v1 API ------------
# Every url added beneath router is prefaced by /api/v1
###
v1 API. Requires api-v1-user (user id) and api-v1-key (api key) headers, Test with:
$ cd node_modules/racer && npm install && cd ../..
$ mocha test/api.mocha.coffee
###
###
API Status
###
router.get '/status', (req, res) ->
res.json status: 'up'
###
beforeEach auth interceptor
###
auth = (req, res, next) ->
uid = req.headers['x-api-user']
token = req.headers['x-api-key']
return res.json 401, NO_TOKEN_OR_UID unless uid || token
model = req.getModel()
query = model.query('users').withIdAndToken(uid, token)
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 _.isEmpty(req.userObj)
req._isServer = true
req.habit.result = data: _.extend(result, delta: delta)
next()
###
GET /user
Get all tasks
###
router.get '/user', auth, (req, res) ->
user = req.userObj
user.stats.toNextLevel = algos.tnl user.stats.lvl
user.stats.maxHealth = 50
delete user.apiToken
if user.auth
delete user.auth.hashed_password
delete user.auth.salt
res.json user
api.getTasks = (req, res, next) ->
return res.json 400, NO_USER_FOUND if _.isEmpty(req.habit.userObj)
types =
if /^(habit|todo|daily|reward)$/.test(req.query.type) then [req.query.type]
else ['habit','todo','daily','reward']
tasks = _.toArray (_.filter req.habit.user.get('tasks'), (t)-> t.type in types)
req.habit.result = data: tasks
next()
###
POST /user/auth/local
Get Task
###
router.post '/user/auth/local', (req, res) ->
username = req.body.username
password = req.body.password
return res.json 401, err: 'No username or password' unless username and password
model = req.getModel()
q = model.query("users").withUsername(username)
q.fetch (err, result1) ->
return res.json 401, { err } if err
u1 = result1.get()
return res.json 401, err: 'Username not found' unless u1 # user not found
# We needed the whole user object first so we can get his salt to encrypt password comparison
q = model.query("users").withLogin(username, utils.encryptPassword(password, u1.auth.local.salt))
q.fetch (err, result2) ->
return res.json 401, { err } if err
# joshua tree?
u2 = result2.get()
return res.json 401, err: 'Incorrect password' unless u2
res.json
id: u2.id
token: u2.apiToken
###
POST /user/auth/facebook
###
router.post '/user/auth/facebook', (req, res) ->
{facebook_id, email, name} = req.body
return res.json 401, err: 'No facebook id provided' unless facebook_id
model = req.getModel()
q = model.query("users").withProvider('facebook', facebook_id)
q.fetch (err, result) ->
return res.json 401, { err } if err
u = result.get()
console.log {facebook_id, u}
if u
return res.json
id: u.id
token: u.apiToken
else
# FIXME: create a new user instead
return res.json 403, err: "Please register with Facebook on https://habitrpg.com, then come back here and log in."
###
GET /user/task/:id
###
router.get '/user/task/:id', auth, (req, res) ->
task = req.userObj.tasks[req.params.id]
api.getTask = (req, res, next) ->
task = req.habit.userObj.tasks[req.params.id]
return res.json 400, err: "No task found." if !task || _.isEmpty(task)
res.json 200, task
req.habit.result = data: task
next()
###
validate task
Validate task
###
validateTask = (req, res, next) ->
api.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' or req.method is 'DELETE'
task = req.userObj?.tasks[req.params.id]
task = req.habit.userObj?.tasks[req.params.id]
return res.json 400, err: "No task found." if !task || _.isEmpty(task)
# Strip for now
type = undefined
@ -214,25 +161,19 @@ validateTask = (req, res, next) ->
newTask.completed = false unless typeof completed is 'boolean'
_.extend task, newTask
req.task = task
req.habit.task = task
next()
###
PUT /user/task/:id
Delete Task
###
router.put '/user/task/:id', auth, validateTask, (req, res) ->
req.user.set "tasks.#{req.task.id}", req.task
res.json 200, req.task
api.deleteTask = (req, res, next) ->
deleteTask req.habit.user, req.habit.task, ->
req.habit.result = code: 204
next()
###
DELETE /user/task/:id
###
router.delete '/user/task/:id', auth, validateTask, (req, res) ->
deleteTask req.user, req.task.type, req.task.id
res.send 204
###
POST /user/tasks
Helper function for updating multiple tasks
###
updateTasks = (tasks, user, model) ->
for idx, task of tasks
@ -253,127 +194,191 @@ updateTasks = (tasks, user, model) ->
tasks[idx] = task
return tasks
router.post '/user/tasks', auth, (req, res) ->
tasks = updateTasks req.body, req.user, req.getModel()
res.json 201, tasks
###
Update Task
###
api.updateTask = (req, res, next) ->
req.habit.user.set "tasks.#{req.habit.task.id}", req.habit.task
req.habit.result = data: req.habit.task
next()
###
POST /user/task/
Update tasks (plural). This will update, add new, delete, etc all at once.
Should we keep this?
###
router.post '/user/task', auth, validateTask, (req, res) ->
task = req.task
addTask req.user, task
res.json 201, task
api.updateTasks = (req, res, next) ->
tasks = updateTasks req.body, req.habit.user, req.getModel()
req.habit.result = code: 201, data: tasks
next()
api.createTask = (req, res, next) ->
task = req.habit.task
addTask req.habit.user, task
req.habit.result = code: 201, data: task
next()
api.sortTask = (req, res, next) ->
{id} = req.params
{to, from, type} = req.habit.task
{user} = req.habit
path = "#{type}Ids"
a = user.get(path)
a.splice(to, 0, a.splice(from, 1)[0])
user.set path, a, next
###
GET /user/tasks
------------------------------------------------------------------------
User
------------------------------------------------------------------------
###
router.get '/user/tasks', auth, (req, res) ->
return res.json 400, NO_USER_FOUND if _.isEmpty(req.userObj)
types =
if /^(habit|todo|daily|reward)$/.test(req.query.type) then [req.query.type]
else ['habit','todo','daily','reward']
tasks = _.toArray (_.filter req.user.get('tasks'), (t)-> t.type in types)
res.json 200, tasks
###
PUT /user
Get User
###
router.put '/user', auth, (req, res, next) ->
api.getUser = (req, res, next) ->
uObj = req.habit.userObj
uObj.stats.toNextLevel = algos.tnl uObj.stats.lvl
uObj.stats.maxHealth = 50
delete uObj.apiToken
if uObj.auth
delete uObj.auth.hashed_password
delete uObj.auth.salt
req.habit.result = data: uObj
next()
###
Register new user with uname / password
###
api.loginLocal = (req, res, next) ->
username = req.body.username
password = req.body.password
return res.json 401, err: 'No username or password' unless username and password
model = req.getModel()
q = model.query("users").withUsername(username)
q.fetch (err, result1) ->
return res.json 401, { err } if err
u1 = result1.get()
return res.json 401, err: 'Username not found' unless u1 # user not found
# We needed the whole user object first so we can get his salt to encrypt password comparison
q = model.query("users").withLogin(username, utils.encryptPassword(password, u1.auth.local.salt))
q.fetch (err, result2) ->
return res.json 401, { err } if err
# joshua tree?
u2 = result2.get()
return res.json 401, err: 'Incorrect password' unless u2
req.habit ?= {}
req.habit.result = data:
id: u2.id
token: u2.apiToken
next()
###
POST /user/auth/facebook
###
api.loginFacebook = (req, res, next) ->
{facebook_id, email, name} = req.body
return res.json 401, err: 'No facebook id provided' unless facebook_id
model = req.getModel()
q = model.query("users").withProvider('facebook', facebook_id)
q.fetch (err, result) ->
return res.json 401, { err } if err
u = result.get()
if u
req.habit ?= {}
req.habit.result = data:
id: u.id
token: u.apiToken
next()
else
# FIXME: create a new user instead
return res.json 403, err: "Please register with Facebook on https://habitrpg.com, then come back here and log in."
###
Update user
FIXME add documentation here
###
api.updateUser = (req, res, next) ->
{user} = req.habit
# FIXME we need to do some crazy sanitiazation if they're using the old `PUT /user {data}` method.
# The new `PUT /user {'stats.hp':50}
# FIXME - one-by-one we want to widdle down this list, instead replacing each needed set path with API operations
# Note: custom is for 3rd party apps
acceptableAttrs = 'achievements filters flags invitations items lastCron party preferences profile stats tags custom'.join(' ')
acceptableAttrs = 'achievements filters flags invitations items lastCron party preferences profile stats tags custom'.split(' ')
series = []
_.each req.body, (v, k) ->
if (_.find acceptableAttrs, (attr)-> k.indexOf(attr) is 0)?
series.push (cb) -> req.user.set(k, v, cb)
series.push (cb) -> req.habit.user.set(k, v, cb)
async.series series, (err) ->
return next(err) if err
res.json 201, helpers.derbyUserToAPI(user)
req.habit.result = data: helpers.derbyUserToAPI(user)
next()
api.cron = (req, res, next) ->
{user} = req.habit
misc.batchTxn req.getModel(), (uObj, paths) ->
uObj = helpers.derbyUserToAPI(user)
algos.cron uObj, {paths}
, {user, done:next, cron:true}
api.revive = (req, res, next) ->
{user} = req.habit
[uObj, paths] = [user.get(), {}]
algos.revive uObj, {paths}
setOps = []
_.each paths, (v,k) ->
setOps.push ((reviveCb) -> user.set k, helpers.dotGet(k,uObj), reviveCb)
async.series setOps, next
###
POST new actions
------------------------------------------------------------------------
Batch Update
Run a bunch of updates all at once
------------------------------------------------------------------------
###
router.post '/batch-update', auth, (req, res, next) ->
model = req.getModel()
{user} = req
api.batchUpdate = (req, res, next) ->
{user} = req.habit
performAction = (action, cb) ->
task = action.task ? {}
req.params.id = action.data?.id
req.params.direction = action.dir
req.body = action.data
switch action.op
when "cron"
misc.batchTxn model, (uObj, paths) ->
uObj = helpers.derbyUserToAPI(user)
algos.cron uObj, {paths}
, {user, done:cb, cron:true}
api.cron(req, res, cb)
when "score"
return cb() unless user.get "tasks.#{task.id}"
sendScore = -> score(model, user, task.id, action.dir, cb)
if task.type in ["daily","todo"]
# switch completed state. Since checkbox is not binded to model unlike when you click through Derby website.
completed = if action.dir is "up" then true else false
user.set "tasks.#{task.id}.completed", completed, sendScore
else sendScore()
api.scoreTask(req, res, cb)
when "sortTask"
path = action.task.type + "Ids"
a = user.get(path)
a.splice(action.to, 0, a.splice(action.from, 1)[0])
user.set path, a, cb
api.sortTask(req, res, cb)
when "addTask"
addTask user, task, cb
api.validateTask req, res, ->
api.createTask(req, res, cb)
when "delTask"
deleteTask user, task, cb
# this API is only working with string or number variables. It should return error if object given or object is at the path.
api.deleteTask(req, res, cb)
when "set"
oldValue = user.get(action.path)
if _.isObject(action.value) or _.isObject(oldValue)
console.error "action.value was an object, which isn't currently supported. Tyler - double check this"
cb()
else
user.set action.path, action.value, cb
api.updateUser(req, res, cb)
when "revive"
[uObj, paths] = [user.get(), {}]
algos.revive uObj, {paths}
setOps = _.map paths, (v,k) ->
(reviveCb) -> user.set k, helpers.dotGet(k,uObj), reviveCb
console.log setOps
async.serial setOps, cb
else
cb()
api.revive(req, res, cb)
else cb()
# Setup the array of functions we're going to call in parallel with async
# Start with cron
(req.body or= []).unshift({op: 'cron'})
actions = _.transform (req.body), (result, action) ->
result.push (cb) -> performAction(action, cb) unless _.isEmpty(action)
actions = [{op: 'cron'}].concat(_.cloneDeep(req.body) ? []) # Start with cron
actions = _.transform (actions), (result, action) ->
unless _.isEmpty(action)
result.push (cb) -> performAction(action, cb)
# call all the operations, then return the user object to the requester
async.series actions, (err) ->
return next(err) if err
res.json 200, helpers.derbyUserToAPI(user)
console.log "Reply sent"
###
POST /user/tasks/:taskId/:direction
###
router.post '/user/task/:taskId/:direction', auth, scoreTask
router.post '/user/tasks/:taskId/:direction', auth, scoreTask
module.exports = router
module.exports.auth = auth
module.exports.scoreTask = scoreTask # export so deprecated can call it
console.log "Reply sent"

View file

@ -76,7 +76,7 @@ mongo_store = new MongoStore {url: process.env.NODE_DB_URI}, ->
.use(store.modelMiddleware())
.use(middleware.translate)
# API should be hit before all other routes
.use('/api/v1', require('./api').middleware)
.use('/api/v1', require('./routes').middleware)
.use(require('./deprecated').middleware)
# Show splash page for newcomers
.use(middleware.splash)

51
src/server/routes.coffee Normal file
View file

@ -0,0 +1,51 @@
express = require 'express'
router = new express.Router()
api = require './api'
###
---------- /api/v1 API ------------
Every url added to router is prefaced by /api/v1
See ./routes/coffee for routes
v1 API. Requires x-api-user (user id) and x-api-key (api key) headers, Test with:
$ cd node_modules/racer && npm install && cd ../..
$ mocha test/api.mocha.coffee
###
{auth, validateTask} = api
###
We don't want the api functions to actually res.send results (unless there was an error)
because we'll be re-using the same functions when apiv2 rolls around, but returning different results.
So handle sending results for apiv1 here
###
v1Send = (req, res, next) ->
{result} = req.habit
if (result.code and result.data) then res.json result.code, result.data
else if result.code then res.send result.code
else if result.data then res.json result.data
else res.send 200
router.get '/status', (req, res) -> res.json status: 'up'
# Scoring
router.post '/user/task/:id/:direction', auth, api.scoreTask, v1Send
router.post '/user/tasks/:id/:direction', auth, api.scoreTask, v1Send
# Tasks
router.get '/user/tasks', auth, api.getTasks, v1Send # plural
router.get '/user/task/:id', auth, api.getTask, v1Send
router.put '/user/task/:id', auth, validateTask, api.updateTask, v1Send
router.post '/user/tasks', auth, api.updateTasks, v1Send # plural
router.delete '/user/task/:id', auth, validateTask, api.deleteTask, v1Send
router.post '/user/task', auth, validateTask, api.createTask, v1Send
router.put '/user/task/:id/sort', auth, validateTask, api.sortTask, v1Send
# User
router.get '/user', auth, api.getUser, v1Send
router.post '/user/auth/local', api.loginLocal, v1Send
router.post '/user/auth/facebook', api.loginFacebook, v1Send
router.put '/user', auth, api.updateUser, v1Send
router.post '/user/batch-update', auth, api.batchUpdate # this one we're handling specially
module.exports = router

View file

@ -379,7 +379,7 @@ describe 'API', ->
.send(user: userUpdates)
.end (res) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 201
expect(res.statusCode).to.be 200
tasks = res.body.tasks
expect(_.find(tasks,{id:habitId})).to.eql {id: habitId,text: 'hello2',notes: 'note2'}
@ -421,7 +421,7 @@ describe 'API', ->
id: userAuth.facebook_id
name: userAuth.name
email: userAuth.email
console.log {newUser}
#console.log {newUser}
model.set "users.#{id}", newUser, ->
request.post("#{baseURL}/user/auth/facebook")
@ -434,17 +434,17 @@ describe 'API', ->
#expect(res.body.token).to.be newUser.apiToken
done()
it.only 'POST /api/v2', (done) ->
it 'PUT /api/v1/batch-update', (done) ->
userBefore = {}
# user.set "lastCron", +new Date #FIXME this shouldn't be handled here
query = model.query('users').withIdAndToken(currentUser.id, currentUser.apiToken)
query.fetch (err, user) -> userBefore = user.get()
console.log {userBefore}
#console.log {userBefore}
jsonRaw =
[{"op":"score","task":{"completed":true,"date":null,"down":null,"id":"049ee706-7992-408f-8bdd-a0f87b6cddee","notes":null,"price":null,"priority":null,"streak":1,"text":"asdasd","type":"daily","up":null,"value":-15.159032750819472},"dir":"down"},{"op":"score","task":{"completed":true,"date":null,"down":null,"id":"049ee706-7992-408f-8bdd-a0f87b6cddee","notes":null,"price":null,"priority":null,"streak":1,"text":"asdasd","type":"daily","up":null,"value":-15.159032750819472},"dir":"up"},{},{},{},{},{"op":"score","task":{"completed":true,"date":null,"down":null,"id":"049ee706-7992-408f-8bdd-a0f87b6cddee","notes":null,"price":null,"priority":null,"streak":1,"text":"asdasd","type":"daily","up":null,"value":-16.63136866553572},"dir":"down"},{"op":"score","task":{"completed":true,"date":null,"down":null,"id":"049ee706-7992-408f-8bdd-a0f87b6cddee","notes":null,"price":null,"priority":null,"streak":1,"text":"asdasd","type":"daily","up":null,"value":-16.63136866553572},"dir":"up"},{},{},{"op":"score","task":{"completed":true,"date":null,"down":null,"history":[{"date":1370796966979,"value":-1.9263318037820194},{"date":1371394179245,"value":-9.632667221983818},{"date":1371987764419,"value":-8.899142684639843},{"date":1371901709065,"value":-8.697714139260505},{"date":1371987764419,"value":-9.947389352351902},{"date":1372072605105,"value":-8.65704735207246},{"date":1372185464158,"value":-9.905420946093672},{"date":1372348155721,"value":-8.616465915449927},{"date":1372404365115,"value":-7.369389857231249},{"date":1372619315210,"value":-6.16153655829917},{"date":1372797766170,"value":-4.990495966065229},{"date":1373266931264,"value":-12.356300657493207},{"date":1373322727209,"value":-10.983796434663102},{"date":1373407801484,"value":-9.658725758132753},{"date":1373639117325,"value":-8.377893411957398},{"date":1373719671601,"value":-7.138418159258412},{"date":1373826521297,"value":-5.902428899644443},{"date":1373839445467,"value":-8.264210410818091},{"date":1373929050162,"value":-8.224444248693572},{"date":1374058202835,"value":-9.459055183497052},{"date":1374102966396,"value":-8.184759693251706},{"date":1374187619046,"value":-6.951403643619735},{"date":1374342649005,"value":-5.72148344218024},{"date":1374434356841,"value":-8.072174632205511},{"date":1374562742400,"value":-10.571154014907808},{"date":1374886027789,"value":-16.097395454285916},{"date":1375011848715,"value":-17.607991906162557},{"date":1375436884647,"value":-16.037773949824242},{"date":1375563074478,"value":-17.546064223707074},{"date":1375568230260,"value":-19.11379232897715},{"date":1375734631073,"value":-20.745784396408645},{"date":1375785010434,"value":-18.767794224069412},{"date":1375826853480,"value":-18.636651213045212}],"id":"fe4b9061-eb58-468c-9b25-10c72be772e6","notes":"","price":null,"priority":null,"repeat":{"su":true,"m":true,"t":true,"w":true,"th":true,"f":true,"s":true},"streak":1,"tags":{"40492758-1202-4d85-8cb3-d40e45f4dd1d":false},"text":"Read 50 pages","type":"daily","up":null,"value":-17.024492021142215},"dir":"up"},{},{},{}]
request.post("http://localhost:1337/api/v2")
request.put("http://localhost:1337/api/v1/batch-update")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)