-
Notifications
You must be signed in to change notification settings - Fork 48
/
Copy pathsoda-js.coffee
321 lines (247 loc) · 9.26 KB
/
soda-js.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
# soda.coffee -- chained, evented, buzzworded library for accessing SODA via JS.
# sodaOpts options:
# username: https basic auth username
# password: https basic auth password
# apiToken: socrata api token
#
# emitterOpts: options to override EventEmitter2 declaration options
# TODO:
# * we're inconsistent about validating query correctness. do we continue with catch-what-we-can,
# or do we just back off and leave all failures to the api to return?
eelib = require('eventemitter2')
EventEmitter = eelib.EventEmitter2 || eelib
httpClient = require('superagent')
# internal util funcs
isString = (obj) -> typeof obj == 'string'
isArray = (obj) -> Array.isArray(obj)
isNumber = (obj) -> !isNaN(parseFloat(obj))
extend = (target, sources...) -> (target[k] = v for k, v of source) for source in sources; null
# it's really, really, really stupid that i have to solve this problem here
toBase64 =
if Buffer?
(str) -> new Buffer(str).toString('base64')
else
# adapted/modified from https://github.com/rwz/base64.coffee
base64Lookup = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='.split('')
rawToBase64 = btoa ? (str) ->
result = []
i = 0
while i < str.length
chr1 = str.charCodeAt(i++)
chr2 = str.charCodeAt(i++)
chr3 = str.charCodeAt(i++)
throw new Error('Invalid character!') if Math.max(chr1, chr2, chr3) > 0xFF
enc1 = chr1 >> 2
enc2 = ((chr1 & 3) << 4) | (chr2 >> 4)
enc3 = ((chr2 & 15) << 2) | (chr3 >> 6)
enc4 = chr3 & 63
if isNaN(chr2)
enc3 = enc4 = 64
else if isNaN(chr3)
enc4 = 64
result.push(base64Lookup[enc1])
result.push(base64Lookup[enc2])
result.push(base64Lookup[enc3])
result.push(base64Lookup[enc4])
result.join('')
(str) -> rawToBase64(unescape(encodeURIComponent(str)))
handleLiteral = (literal) ->
if isString(literal)
"'#{literal}'"
else if isNumber(literal)
# TODO: possibly ensure number cleanliness for sending to the api? sci not?
literal
else
literal
handleOrder = (order) ->
if /( asc$| desc$)/i.test(order)
order
else
order + ' asc'
addExpr = (target, args) ->
for arg in args
if isString(arg)
target.push(arg)
else
target.push("#{k} = #{handleLiteral(v)}") for k, v of arg
# extern util funcs
# convenience functions for building where clauses, if so desired
expr =
and: (clauses...) -> ("(#{clause})" for clause in clauses).join(' and ')
or: (clauses...) -> ("(#{clause})" for clause in clauses).join(' or ')
gt: (column, literal) -> "#{column} > #{handleLiteral(literal)}"
gte: (column, literal) -> "#{column} >= #{handleLiteral(literal)}"
lt: (column, literal) -> "#{column} < #{handleLiteral(literal)}"
lte: (column, literal) -> "#{column} <= #{handleLiteral(literal)}"
eq: (column, literal) -> "#{column} = #{handleLiteral(literal)}"
# serialize object to querystring
toQuerystring = (obj) ->
str = []
for own key, val of obj
str.push encodeURIComponent(key) + '=' + encodeURIComponent(val)
str.join '&'
class Connection
constructor: (@dataSite, @sodaOpts = {}) ->
throw new Error('dataSite does not appear to be valid! Please supply a domain name, eg data.seattle.gov') unless /^[a-z0-9-_.]+(:[0-9]+)?$/i.test(@dataSite)
# options passed directly into EventEmitter2 construction
@emitterOpts = @sodaOpts.emitterOpts ?
wildcard: true,
delimiter: '.',
maxListeners: 15
@networker = (opts, data) ->
url = "https://#{@dataSite}#{opts.path}"
client = httpClient(opts.method, url)
client.set('Accept', "application/json") if data?
client.set('Content-type', "application/json") if data?
client.set('X-App-Token', @sodaOpts.apiToken) if @sodaOpts.apiToken?
client.set('Authorization', "Basic " + toBase64("#{@sodaOpts.username}:#{@sodaOpts.password}")) if @sodaOpts.username? and @sodaOpts.password?
client.set('Authorization', "OAuth " + accessToken) if @sodaOpts.accessToken?
client.query(opts.query) if opts.query?
client.send(data) if data?
(responseHandler) => client.end(responseHandler || @getDefaultHandler())
getDefaultHandler: ->
# instance variable for easy chaining
@emitter = emitter = new EventEmitter(@emitterOpts)
# return the handler
handler = (error, response) ->
# TODO: possibly more granular handling?
if response.ok
if response.accepted
# handle 202 by remaking request. inform of possible progress.
emitter.emit('progress', response.body)
setTimeout((-> @consumer.networker(opts)(handler)), 5000)
else
emitter.emit('success', response.body)
else
emitter.emit('error', response.body ? response.text)
# just emit the raw superagent obj if they just want complete event
emitter.emit('complete', response)
# main class
class Consumer
constructor: (@dataSite, @sodaOpts = {}) ->
@connection = new Connection(@dataSite, @sodaOpts)
query: ->
new Query(this)
getDataset: (id) ->
emitter = new EventEmitter(@emitterOpts)
# TODO: implement me
# Producer class
class Producer
constructor: (@dataSite, @sodaOpts = {}) ->
@connection = new Connection(@dataSite, @sodaOpts)
operation: ->
new Operation(this)
class Operation
constructor: (@producer) ->
withDataset: (datasetId) -> @_datasetId = datasetId; this
# truncate the entire dataset
truncate: ->
opts = method: 'delete'
opts.path = "/resource/#{@_datasetId}"
this._exec(opts)
# add a new row - explicitly avoids upserting (updating/deleting existing rows)
add: (data) ->
opts = method: 'post'
opts.path = "/resource/#{@_datasetId}"
_data = JSON.parse(JSON.stringify(data))
delete _data[':id']
delete _data[':delete']
for obj in _data
delete obj[':id']
delete obj[':delete']
this._exec(opts, _data)
# modify existing rows
delete: (id) ->
opts = method: 'delete'
opts.path = "/resource/#{@_datasetId}/#{id}"
this._exec(opts)
update: (id, data) ->
opts = method: 'post'
opts.path = "/resource/#{@_datasetId}/#{id}"
this._exec(opts, data)
replace: (id, data) ->
opts = method: 'put'
opts.path = "/resource/#{@_datasetId}/#{id}"
this._exec(opts, data)
# add objects, update if existing, delete if :delete=true
upsert: (data) ->
opts = method: 'post'
opts.path = "/resource/#{@_datasetId}"
this._exec(opts, data)
_exec: (opts, data) ->
throw new Error('no dataset given to work against!') unless @_datasetId?
@producer.connection.networker(opts, data)()
@producer.connection.emitter
# querybuilder class
class Query
constructor: (@consumer) ->
@_select = []
@_where = []
@_group = []
@_having = []
@_order = []
@_offset = @_limit = @_q = null
withDataset: (datasetId) -> @_datasetId = datasetId; this
# for passing in a fully formed soql query. all other params will be ignored
soql: (query) -> @_soql = query; this
select: (selects...) -> @_select.push(select) for select in selects; this
# args: ('clause', [...])
# ({ column: value1, columnb: value2 }, [...]])
# multiple calls are assumed to be and-chained
where: (args...) -> addExpr(@_where, args); this
having: (args...) -> addExpr(@_having, args); this
group: (groups...) -> @_group.push(group) for group in groups; this
# args: ("column direction", ["column direction", [...]])
order: (orders...) -> @_order.push(handleOrder(order)) for order in orders; this
offset: (offset) -> @_offset = offset; this
limit: (limit) -> @_limit = limit; this
q: (q) -> @_q = q; this
getOpts: ->
opts = method: 'get'
throw new Error('no dataset given to work against!') unless @_datasetId?
opts.path = "/resource/#{@_datasetId}.json"
queryComponents = this._buildQueryComponents()
opts.query = {}
opts.query['$' + k] = v for k, v of queryComponents
opts
getURL: ->
opts = this.getOpts()
query = toQuerystring(opts.query)
"https://#{@consumer.dataSite}#{opts.path}" + (if query then "?#{query}" else "")
getRows: ->
opts = this.getOpts()
@consumer.connection.networker(opts)()
@consumer.connection.emitter
_buildQueryComponents: ->
query = {}
if @_soql?
query.query = @_soql
else
query.select = @_select.join(', ') if @_select.length > 0
query.where = expr.and.apply(this, @_where) if @_where.length > 0
query.group = @_group.join(', ') if @_group.length > 0
if @_having.length > 0
throw new Error('Having provided without group by!') unless @_group.length > 0
query.having = expr.and.apply(this, @_having)
query.order = @_order.join(', ') if @_order.length > 0
query.offset = @_offset if isNumber(@_offset)
query.limit = @_limit if isNumber(@_limit)
query.q = @_q if @_q
query
class Dataset
constructor: (@data, @client) ->
# TODO: implement me
extend(exports ? this.soda,
Consumer: Consumer,
Producer: Producer,
expr: expr,
# exported for testing reasons
_internal:
Connection: Connection,
Query: Query,
Operation: Operation,
util:
toBase64: toBase64,
handleLiteral: handleLiteral,
handleOrder: handleOrder
)