/
unio.js
309 lines (248 loc) · 9.33 KB
/
unio.js
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
var request = require('request')
var fs = require('fs')
var Seq = require('seq')
var util = require('util')
var querystring = require('querystring')
var path = require('path')
var helper = require('./helper')
module.exports = function () {
return new Unio()
}
// allowed verbs
var VERBS = [
'get',
'patch',
'post',
'put',
'delete'
];
function Unio () {
var self = this
this.specs = {}
// import specs from fs into unio
var specDir = path.resolve(__dirname, '../specs')
var specs = fs.readdirSync(specDir).map(function (specFile) {
return path.resolve(__dirname, '../specs/'+specFile)
})
specs.forEach(self.spec.bind(this))
}
// attach http verbs as methods on `unio`
VERBS.forEach(function (verb) {
Unio.prototype[verb] = function (resource, params, callback) {
if (!this.usingSpec) {
throw new Error('must call Unio.use() first to tell unio which resource to request.')
}
return this.request(verb, resource, params, callback)
}
})
/**
* Import a new REST API spec into Unio
*
* @param {Object or String or Array} spec
*
* `spec` can be:
* (1) Object (single API spec)
* (2) Array (Array of (1))
* (3) String representing:
* - local fs path to json file that
* is parsed as (1) or (2)
*
*/
Unio.prototype.spec = function (spec) {
var self = this
if (Array.isArray(spec)) {
spec.forEach(function (entry) {
self.addSpec(entry)
})
return this
} else if (typeof spec === 'object') {
return this.addSpec(spec)
} else if (typeof spec === 'string') {
//expects file on fs to be a json file
//or js file exporting an object
if (fs.existsSync(spec)) {
spec = require(spec)
return this.addSpec(spec)
} else {
throw new Error('string argument passed to `unio.spec()` does not exist on the local fs')
}
} else {
throw new Error('unsupported type supplied as first argument to `unio.spec()`. Got:'+typeof spec)
}
}
Unio.prototype.addSpec = function (spec) {
if (this.specs[spec.name]) {
throw new Error('spec with this name already exists.')
}
this.specs[spec.name] = spec
return this
}
Unio.prototype.use = function (specName) {
if (!this.specs[specName])
throw new Error('Cannot use `'+specName+'`. Call unio.spec() to add this spec before calling .use().')
this.usingSpec = this.specs[specName]
return this
}
/**
* Finalize and send the request, then pass control to `callback`.
*
* @param {String} verb http verb e.g. get, post
* @param {String} resource API resource e.g. search/tweets
* @param {Function} callback completion callback. Signature: function (err, res, reply) {}
*/
Unio.prototype.request = function (verb, resource, params, callback) {
var self = this
var validCallback = callback && typeof callback === 'function'
verb = verb.toLowerCase()
if (typeof params === 'function') {
callback = params
params = {}
}
// determine the matching resource from the spec
var specResource = self.findMatchingResource(verb, resource)
var specErr = new Error(verb.toUpperCase()+' '+resource+' not supported for API `'+this.usingSpec.name+'`. Make sure the spec is correct, or `.use()` the correct API.')
if (!specResource) {
if (validCallback) return callback(specErr)
throw specErr
}
// validate `params` against the currently used spec
Object.keys(specResource.params).forEach(function (keyName) {
var isRequired = specResource.params[keyName] === 'required'
var validationErr = new Error('Invalid request: params object must have `'+keyName+'`. It is listed as a required parameter in the spec.')
if (isRequired && typeof params[keyName] === 'undefined') {
if (validCallback) return callback(validationErr)
else throw validationErr
}
})
var reqOpts = self.buildRequestOpts(verb, resource, specResource, params)
// console.log('\nfinal reqOpts', util.inspect(reqOpts, true, 10, true))
return request(reqOpts, function (err, res, body) {
// pass error to callback, or throw if no callback was passed in
if (err) {
if (validCallback)
return callback(err, null)
throw err
}
var parsed = null
// attempt to parse the string as JSON
// if we fail, pass the callback the raw response body
try {
parsed = JSON.parse(body)
} catch (e) {
parsed = body
} finally {
if (validCallback)
return callback(null, res, parsed)
}
})
}
/**
* Find the first matching API resource for `verb` and `resource`
* from the spec we are currently using.
*
* @param {String} verb HTTP verb; eg. 'get', 'post'.
* @param {String} resource user's requested resource.
* @return {Object} matching resource object from the spec.
*/
Unio.prototype.findMatchingResource = function (verb, resource) {
var self = this
var specResource = null
var resourceCandidates = this.usingSpec.resources
// find the first matching resource in the spec, by
// checking the name and then the path of each resource in the spec
resourceCandidates.some(function (candidate, index) {
var normName = candidate.name && self.normalizeUri(candidate.name)
var normPath = candidate.path && self.normalizeUri(candidate.path)
var rgxName = new RegExp(normName)
var rgxPath = new RegExp(normPath)
// check for a match in the resource name or path
var nameMatch = (normName && rgxName.test(resource))
|| (normPath && rgxPath.test(resource))
// check that the verbs allowed with this resource match `verb`
var verbMatch = candidate.methods.indexOf(verb) !== -1
// console.log('nameMatch: %s, candidate.name: %s, candidate.path: %s, resource: %s, rgxName: %s', nameMatch, candidate.name, candidate.path, resource, rgxName)
if (nameMatch && verbMatch) {
specResource = self.usingSpec.resources[index]
return true
}
})
return specResource
}
Unio.prototype.buildRequestOpts = function (verb, resource, specResource, params) {
var self = this
var paramsClone = helper.clone(params)
var reqOpts = {
method: verb
}
// determine absolute url to resource
// if resource path is an absolute url, just hit that url directly
var rgxDomain = /^http/
if (rgxDomain.test(specResource.path)) {
reqOpts.url = specResource.path
} else {
var queryPath = specResource.path ? specResource.path : resource
// otherwise append the resource url fragment to the api root
reqOpts.url = this.usingSpec.api_root + '/' + queryPath
}
var rgxParam = /\/:(\w+)/g
// url-encode all parameters needed to build the url,
// and strip them from `paramsClone`
// if /:params are used in the resource path, populate them
reqOpts.url = reqOpts.url.replace(rgxParam, function (hit) {
var paramName = hit.slice(2)
var userValue = paramsClone[paramName]
// if user supplied extra values in the params object that
// the spec doesn't take, ignore them
if (!userValue) {
var missingUrlParamErr = new Error('Params object is missing a required parameter from url path: '+paramName+'.')
throw new Error(missingUrlParamErr)
}
var paramVal = helper.clone(userValue)
// strip this off `paramsClone`, so we don't also encode it
// in the querystring or body of `reqOpts`
delete paramsClone[paramName]
// console.log('paramName: %s. paramVal: %s', paramName, paramVal)
return '/' + paramVal
})
// encode the oauth params (if specified) and strip from `paramsClone`
if (paramsClone.oauth) {
// handle oauth info from params
var oauthClone = helper.clone(paramsClone.oauth)
reqOpts.oauth = paramsClone.oauth
delete paramsClone.oauth
}
// encode the rest of the parameters as appropriate (querystring or body)
if ([ 'post', 'put', 'patch' ].indexOf(verb) !== -1) {
reqOpts.body = self.urlEncode(paramsClone)
// encode the body as JSON
reqOpts.json = true
} else {
// otherwise encode any remaining params as querystring
if (Object.keys(paramsClone).length)
reqOpts.qs = paramsClone
}
return reqOpts
}
Unio.prototype.urlEncode = function (obj) {
return querystring.stringify(obj)
.replace(/\!/g, "%21")
.replace(/\'/g, "%27")
.replace(/\(/g, "%28")
.replace(/\)/g, "%29")
.replace(/\*/g, "%2A")
}
/**
* Normalize `uri` string to its corresponding regex string.
* Used for matching unio requests to the appropriate resource.
*
* @param {String} uri
* @return {String}
*/
Unio.prototype.normalizeUri = function (uri) {
var normUri = uri
// normalize :params
.replace(/:w+/g, ':w+')
// string forward slash -> regex match for forward slash
.replace(/\//g, '\\/')
return '^'+normUri+'$'
}