UNPKG

soda-js

Version:

js library for accessing a soda2 api

322 lines (247 loc) 9.48 kB
# 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 )