-
Notifications
You must be signed in to change notification settings - Fork 117
/
restivus.coffee
357 lines (315 loc) · 12.5 KB
/
restivus.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
class @Restivus
constructor: (options) ->
@_routes = []
@_config =
paths: []
useDefaultAuth: false
apiPath: 'api/'
version: null
prettyJson: false
auth:
token: 'services.resume.loginTokens.hashedToken'
user: ->
if @request.headers['x-auth-token']
token = Accounts._hashLoginToken @request.headers['x-auth-token']
userId: @request.headers['x-user-id']
token: token
defaultHeaders:
'Content-Type': 'application/json'
enableCors: true
# Configure API with the given options
_.extend @_config, options
if @_config.enableCors
corsHeaders =
'Access-Control-Allow-Origin': '*'
'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept'
if @_config.useDefaultAuth
corsHeaders['Access-Control-Allow-Headers'] += ', X-User-Id, X-Auth-Token'
# Set default header to enable CORS if configured
_.extend @_config.defaultHeaders, corsHeaders
if not @_config.defaultOptionsEndpoint
@_config.defaultOptionsEndpoint = ->
@response.writeHead 200, corsHeaders
@done()
# Normalize the API path
if @_config.apiPath[0] is '/'
@_config.apiPath = @_config.apiPath.slice 1
if _.last(@_config.apiPath) isnt '/'
@_config.apiPath = @_config.apiPath + '/'
# URL path versioning is the only type of API versioning currently available, so if a version is
# provided, append it to the base path of the API
if @_config.version
@_config.apiPath += @_config.version + '/'
# Add default login and logout endpoints if auth is configured
if @_config.useDefaultAuth
@_initAuth()
else if @_config.useAuth
@_initAuth()
console.warn 'Warning: useAuth API config option will be removed in Restivus v1.0 ' +
'\n Use the useDefaultAuth option instead'
return this
###*
Add endpoints for the given HTTP methods at the given path
@param path {String} The extended URL path (will be appended to base path of the API)
@param options {Object} Route configuration options
@param options.authRequired {Boolean} The default auth requirement for each endpoint on the route
@param options.roleRequired {String or String[]} The default role required for each endpoint on the route
@param endpoints {Object} A set of endpoints available on the new route (get, post, put, patch, delete, options)
@param endpoints.<method> {Function or Object} If a function is provided, all default route
configuration options will be applied to the endpoint. Otherwise an object with an `action`
and all other route config options available. An `action` must be provided with the object.
###
addRoute: (path, options, endpoints) ->
# Create a new route and add it to our list of existing routes
route = new share.Route(this, path, options, endpoints)
@_routes.push(route)
route.addToApi()
return this
###*
Generate routes for the Meteor Collection with the given name
###
addCollection: (collection, options={}) ->
methods = ['get', 'post', 'put', 'patch', 'delete', 'getAll']
methodsOnCollection = ['post', 'getAll']
# Grab the set of endpoints
if collection is Meteor.users
collectionEndpoints = @_userCollectionEndpoints
else
collectionEndpoints = @_collectionEndpoints
# Flatten the options and set defaults if necessary
endpointsAwaitingConfiguration = options.endpoints or {}
routeOptions = options.routeOptions or {}
excludedEndpoints = options.excludedEndpoints or []
# Use collection name as default path
path = options.path or collection._name
# Separate the requested endpoints by the route they belong to (one for operating on the entire
# collection and one for operating on a single entity within the collection)
collectionRouteEndpoints = {}
entityRouteEndpoints = {}
if _.isEmpty(endpointsAwaitingConfiguration) and _.isEmpty(excludedEndpoints)
# Generate all endpoints on this collection
_.each methods, (method) ->
# Partition the endpoints into their respective routes
if method in methodsOnCollection
_.extend collectionRouteEndpoints, collectionEndpoints[method].call(this, collection)
else _.extend entityRouteEndpoints, collectionEndpoints[method].call(this, collection)
return
, this
else
# Generate any endpoints that haven't been explicitly excluded
_.each methods, (method) ->
if method not in excludedEndpoints and endpointsAwaitingConfiguration[method] isnt false
# Configure endpoint and map to it's http method
# TODO: Consider predefining a map of methods to their http method type (e.g., getAll: get)
endpointOptions = endpointsAwaitingConfiguration[method]
configuredEndpoint = {}
_.each collectionEndpoints[method].call(this, collection), (action, methodType) ->
configuredEndpoint[methodType] =
_.chain action
.clone()
.extend endpointOptions
.value()
# Partition the endpoints into their respective routes
if method in methodsOnCollection
_.extend collectionRouteEndpoints, configuredEndpoint
else _.extend entityRouteEndpoints, configuredEndpoint
return
, this
# Add the routes to the API
@addRoute path, routeOptions, collectionRouteEndpoints
@addRoute "#{path}/:id", routeOptions, entityRouteEndpoints
return this
###*
A set of endpoints that can be applied to a Collection Route
###
_collectionEndpoints:
get: (collection) ->
get:
action: ->
entity = collection.findOne @urlParams.id
if entity
{status: 'success', data: entity}
else
statusCode: 404
body: {status: 'fail', message: 'Item not found'}
put: (collection) ->
put:
action: ->
entityIsUpdated = collection.update @urlParams.id, @bodyParams
if entityIsUpdated
entity = collection.findOne @urlParams.id
{status: 'success', data: entity}
else
statusCode: 404
body: {status: 'fail', message: 'Item not found'}
patch: (collection) ->
patch:
action: ->
entityIsUpdated = collection.update @urlParams.id, $set: @bodyParams
if entityIsUpdated
entity = collection.findOne @urlParams.id
{status: 'success', data: entity}
else
statusCode: 404
body: {status: 'fail', message: 'Item not found'}
delete: (collection) ->
delete:
action: ->
if collection.remove @urlParams.id
{status: 'success', data: message: 'Item removed'}
else
statusCode: 404
body: {status: 'fail', message: 'Item not found'}
post: (collection) ->
post:
action: ->
entityId = collection.insert @bodyParams
entity = collection.findOne entityId
if entity
statusCode: 201
body: {status: 'success', data: entity}
else
statusCode: 400
body: {status: 'fail', message: 'No item added'}
getAll: (collection) ->
get:
action: ->
entities = collection.find().fetch()
if entities
{status: 'success', data: entities}
else
statusCode: 404
body: {status: 'fail', message: 'Unable to retrieve items from collection'}
###*
A set of endpoints that can be applied to a Meteor.users Collection Route
###
_userCollectionEndpoints:
get: (collection) ->
get:
action: ->
entity = collection.findOne @urlParams.id, fields: profile: 1
if entity
{status: 'success', data: entity}
else
statusCode: 404
body: {status: 'fail', message: 'User not found'}
put: (collection) ->
put:
action: ->
entityIsUpdated = collection.update @urlParams.id, $set: profile: @bodyParams
if entityIsUpdated
entity = collection.findOne @urlParams.id, fields: profile: 1
{status: "success", data: entity}
else
statusCode: 404
body: {status: 'fail', message: 'User not found'}
delete: (collection) ->
delete:
action: ->
if collection.remove @urlParams.id
{status: 'success', data: message: 'User removed'}
else
statusCode: 404
body: {status: 'fail', message: 'User not found'}
post: (collection) ->
post:
action: ->
# Create a new user account
entityId = Accounts.createUser @bodyParams
entity = collection.findOne entityId, fields: profile: 1
if entity
statusCode: 201
body: {status: 'success', data: entity}
else
statusCode: 400
{status: 'fail', message: 'No user added'}
getAll: (collection) ->
get:
action: ->
entities = collection.find({}, fields: profile: 1).fetch()
if entities
{status: 'success', data: entities}
else
statusCode: 404
body: {status: 'fail', message: 'Unable to retrieve users'}
###
Add /login and /logout endpoints to the API
###
_initAuth: ->
self = this
###
Add a login endpoint to the API
After the user is logged in, the onLoggedIn hook is called (see Restfully.configure() for
adding hook).
###
@addRoute 'login', {authRequired: false},
post: ->
# Grab the username or email that the user is logging in with
user = {}
if @bodyParams.user
if @bodyParams.user.indexOf('@') is -1
user.username = @bodyParams.user
else
user.email = @bodyParams.user
else if @bodyParams.username
user.username = @bodyParams.username
else if @bodyParams.email
user.email = @bodyParams.email
password = @bodyParams.password
if @bodyParams.hashed
password =
digest: password
algorithm: 'sha-256'
# Try to log the user into the user's account (if successful we'll get an auth token back)
try
auth = Auth.loginWithPassword user, password
catch e
return {} =
statusCode: e.error
body: status: 'error', message: e.reason
# Get the authenticated user
# TODO: Consider returning the user in Auth.loginWithPassword(), instead of fetching it again here
if auth.userId and auth.authToken
searchQuery = {}
searchQuery[self._config.auth.token] = Accounts._hashLoginToken auth.authToken
@user = Meteor.users.findOne
'_id': auth.userId
searchQuery
@userId = @user?._id
response = {status: 'success', data: auth}
# Call the login hook with the authenticated user attached
extraData = self._config.onLoggedIn?.call(this)
if extraData?
_.extend(response.data, {extra: extraData})
response
logout = ->
# Remove the given auth token from the user's account
authToken = @request.headers['x-auth-token']
hashedToken = Accounts._hashLoginToken authToken
tokenLocation = self._config.auth.token
index = tokenLocation.lastIndexOf '.'
tokenPath = tokenLocation.substring 0, index
tokenFieldName = tokenLocation.substring index + 1
tokenToRemove = {}
tokenToRemove[tokenFieldName] = hashedToken
tokenRemovalQuery = {}
tokenRemovalQuery[tokenPath] = tokenToRemove
Meteor.users.update @user._id, {$pull: tokenRemovalQuery}
response = {status: 'success', data: {message: 'You\'ve been logged out!'}}
# Call the logout hook with the authenticated user attached
extraData = self._config.onLoggedOut?.call(this)
if extraData?
_.extend(response.data, {extra: extraData})
response
###
Add a logout endpoint to the API
After the user is logged out, the onLoggedOut hook is called (see Restfully.configure() for
adding hook).
###
@addRoute 'logout', {authRequired: true},
get: ->
console.warn "Warning: Default logout via GET will be removed in Restivus v1.0. Use POST instead."
console.warn " See https://github.com/kahmali/meteor-restivus/issues/100"
return logout.call(this)
post: logout
Restivus = @Restivus