numerousapp 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/numerousapp.rb +785 -0
- metadata +45 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 5d5d927781f67b4b8929965a109a0f1e3398ef79
|
4
|
+
data.tar.gz: 338338af020ac429ce9e8cfe3c2a76cd425f48ba
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: aedd071bc1f66a16f68c1a361e799cb6e6b89c1603dcce55ee9af101e0fe5a0d29b7332bc7424e2f6257dc962e87d68624fc30551e04dcbee21e5809cec81a4d
|
7
|
+
data.tar.gz: b260a3ec6fbe3b8731713f8467e28e1094b43c98700e4fc29e71eaff65b7ecafd7eee4e3440b161e7c84ee194dc10374747f037d09d3edceef710f021f11fd8a
|
data/lib/numerousapp.rb
ADDED
@@ -0,0 +1,785 @@
|
|
1
|
+
#
|
2
|
+
# The MIT License (MIT)
|
3
|
+
#
|
4
|
+
# Copyright (c) 2014 Neil Webber
|
5
|
+
#
|
6
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
7
|
+
# of this software and associated documentation files (the "Software"), to deal
|
8
|
+
# in the Software without restriction, including without limitation the rights
|
9
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
10
|
+
# copies of the Software, and to permit persons to whom the Software is
|
11
|
+
# furnished to do so, subject to the following conditions:
|
12
|
+
#
|
13
|
+
# The above copyright notice and this permission notice shall be included in
|
14
|
+
# all copies or substantial portions of the Software.
|
15
|
+
#
|
16
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
17
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
18
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
19
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
20
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
21
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
22
|
+
# THE SOFTWARE.
|
23
|
+
#
|
24
|
+
# #################
|
25
|
+
# Classes for the NumerousApp API
|
26
|
+
# Numerous
|
27
|
+
# -- server-level operations: get user parameters, create metric, etc
|
28
|
+
#
|
29
|
+
# NumerousMetric
|
30
|
+
# -- individual metrics: read/write/like/comment, etc
|
31
|
+
#
|
32
|
+
# NumerousClientInternals
|
33
|
+
# -- all the hairball stuff for interfacing with numerousApp server
|
34
|
+
# not really meant to be used outside of Numerous/NumerousMetric
|
35
|
+
# #################
|
36
|
+
|
37
|
+
require 'json'
|
38
|
+
require 'net/http'
|
39
|
+
require 'uri'
|
40
|
+
|
41
|
+
class NumerousError < StandardError
|
42
|
+
def initialize(msg, code, details)
|
43
|
+
super(msg)
|
44
|
+
@code = code
|
45
|
+
@details = details
|
46
|
+
end
|
47
|
+
|
48
|
+
attr_accessor :code, :details
|
49
|
+
end
|
50
|
+
|
51
|
+
class NumerousAuthError < NumerousError
|
52
|
+
end
|
53
|
+
|
54
|
+
class NumerousMetricConflictError < NumerousError
|
55
|
+
end
|
56
|
+
|
57
|
+
#
|
58
|
+
# This class is not meant for public consumption but it subclassed
|
59
|
+
# into Numerous and NumerousMetric. It encapsultes all the details
|
60
|
+
# of talking to the numerous server, dealing with chunked APIs, etc.
|
61
|
+
#
|
62
|
+
class NumerousClientInternals
|
63
|
+
|
64
|
+
def initialize(apiKey, server:'api.numerousapp.com')
|
65
|
+
@serverName = server
|
66
|
+
@auth = { user: apiKey, password: "" }
|
67
|
+
u = URI.parse("https://"+server)
|
68
|
+
@http = Net::HTTP.new(server, u.port)
|
69
|
+
@http.use_ssl = true # always required by NumerousApp
|
70
|
+
|
71
|
+
@agentString = "NW-Ruby-NumerousClass/" + VersionString +
|
72
|
+
" (Ruby #{RUBY_VERSION}) NumerousAPI/v2"
|
73
|
+
|
74
|
+
@debugLevel = 0
|
75
|
+
end
|
76
|
+
attr_accessor :agentString
|
77
|
+
|
78
|
+
def debug(lvl=1)
|
79
|
+
prev = @debugLevel
|
80
|
+
@debugLevel = lvl
|
81
|
+
if @debugLevel > 0
|
82
|
+
@http.set_debug_output $stderr
|
83
|
+
else
|
84
|
+
@http.set_debug_output nil
|
85
|
+
end
|
86
|
+
return prev
|
87
|
+
end
|
88
|
+
|
89
|
+
protected
|
90
|
+
|
91
|
+
VersionString = '20141223.1x'
|
92
|
+
|
93
|
+
MethMap = {
|
94
|
+
GET: Net::HTTP::Get,
|
95
|
+
POST: Net::HTTP::Post,
|
96
|
+
PUT: Net::HTTP::Put,
|
97
|
+
DELETE: Net::HTTP::Delete
|
98
|
+
}
|
99
|
+
|
100
|
+
|
101
|
+
#
|
102
|
+
# This gathers all the relevant information for a given API
|
103
|
+
# and fills in the variable fields in URLs. It returns an "api context"
|
104
|
+
# containing all the API-specific details needed by simpleAPI.
|
105
|
+
#
|
106
|
+
|
107
|
+
def makeAPIcontext(info, whichOp, kwargs={})
|
108
|
+
rslt = {}
|
109
|
+
rslt[:httpMethod] = whichOp
|
110
|
+
|
111
|
+
# Build the substitutions from the defaults (if any) and non-nil kwargs.
|
112
|
+
# Note: we are carefully making copies of the underlying dictionaries
|
113
|
+
# so you get your own private context returned to you
|
114
|
+
substitutions = (info[:defaults]||{}).clone
|
115
|
+
|
116
|
+
# copy any supplied non-nil kwargs (nil ones defer to defaults)
|
117
|
+
kwargs.each { |k, v| if v then substitutions[k] = v end }
|
118
|
+
|
119
|
+
# this is the stuff specific to the operation, e.g.,
|
120
|
+
# the 'next' and 'list' fields in a chunked GET
|
121
|
+
# There can also be additional path info.
|
122
|
+
# process the paty appendage and copy everything else
|
123
|
+
|
124
|
+
appendThis = ""
|
125
|
+
path = info[:path]
|
126
|
+
if info[whichOp]
|
127
|
+
opi = info[whichOp]
|
128
|
+
opi.each do |k, v|
|
129
|
+
if k == :appendPath
|
130
|
+
appendThis = v
|
131
|
+
elsif k == :path
|
132
|
+
path = v # entire path overridden on this one
|
133
|
+
else
|
134
|
+
rslt[k] = v
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
rslt[:basePath] = (path + appendThis) % substitutions
|
139
|
+
return rslt
|
140
|
+
end
|
141
|
+
|
142
|
+
# compute a multipart boundary string; excessively paranoid
|
143
|
+
BChars = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".b
|
144
|
+
BCharsLen = BChars.length
|
145
|
+
|
146
|
+
def makeboundary(s)
|
147
|
+
# Just try something fixed, and if it is no good extend it with random
|
148
|
+
b = "RoLlErCaSeDbOuNdArY8675309".b
|
149
|
+
while s.include? b
|
150
|
+
b += BChars[rand(BCharsLen)]
|
151
|
+
end
|
152
|
+
return b
|
153
|
+
end
|
154
|
+
private :makeboundary
|
155
|
+
|
156
|
+
|
157
|
+
# ALL api exchanges with the Numerous server go through here except
|
158
|
+
# for getRedirect() which is a special case (hack) for photo URLs
|
159
|
+
#
|
160
|
+
# Any single request/response uses this; chunked APIs use
|
161
|
+
# the iterator classes (which in turn come back and use this repeatedly)
|
162
|
+
#
|
163
|
+
# The api parameter dictionary specifies:
|
164
|
+
#
|
165
|
+
# basePath - the url we use (without the https://server.com part)
|
166
|
+
# httpMethod' - GET vs POST vs PUT etc
|
167
|
+
# successCodes' - what "OK" responses are (default 200)
|
168
|
+
#
|
169
|
+
# The api parameter may also carry additional info used elsewhere.
|
170
|
+
# See, for example, how the iterators work on collections.
|
171
|
+
#
|
172
|
+
# Sometimes you may have started with a basePath but then been given
|
173
|
+
# a "next" URL to use for subsequent requests. In those cases pass
|
174
|
+
# in a url and it will take precedence over the basePath if any is present
|
175
|
+
#
|
176
|
+
# You can pass in a dictionary jdict which will be json-ified
|
177
|
+
# and sent as Content-Type: application/json. Or you can pass in
|
178
|
+
# a multipart dictionary ... this is used for posting photos
|
179
|
+
# You should not specify both jdict and multipart
|
180
|
+
#
|
181
|
+
def simpleAPI(api, jdict:nil, multipart:nil, url:nil)
|
182
|
+
|
183
|
+
# take the base url if you didn't give us an override
|
184
|
+
url ||= api[:basePath]
|
185
|
+
|
186
|
+
if url[0] == '/' # i.e. not "http..."
|
187
|
+
path = url
|
188
|
+
else
|
189
|
+
# technically we should be able to reassign @http bcs it could
|
190
|
+
# change if server redirected us. But don't want to if no change.
|
191
|
+
# need to add logic. XXX TODO XXX
|
192
|
+
path = URI.parse(url).request_uri
|
193
|
+
end
|
194
|
+
|
195
|
+
rq = MethMap[api[:httpMethod]].new(path)
|
196
|
+
rq.basic_auth(@auth[:user], @auth[:password])
|
197
|
+
rq['user-agent'] = @agentString
|
198
|
+
if jdict
|
199
|
+
rq['content-type'] = 'application/json'
|
200
|
+
rq.body = JSON.generate(jdict)
|
201
|
+
elsif multipart
|
202
|
+
# the data in :f is either a raw string OR a readable file
|
203
|
+
begin
|
204
|
+
f = multipart[:f]
|
205
|
+
img = f.read
|
206
|
+
rescue NoMethodError
|
207
|
+
img = f
|
208
|
+
end
|
209
|
+
boundary = makeboundary(img)
|
210
|
+
|
211
|
+
rq["content-type"] = "multipart/form-data; boundary=#{boundary}"
|
212
|
+
d = []
|
213
|
+
d << "--#{boundary}\r\n"
|
214
|
+
d << "Content-Disposition: form-data;"
|
215
|
+
d << ' name="image";'
|
216
|
+
d << ' filename="image.img";'
|
217
|
+
d << "\r\n"
|
218
|
+
d << "Content-Transfer-Encoding: binary\r\n"
|
219
|
+
d << "Content-Type: #{multipart[:mimeType]}\r\n"
|
220
|
+
d << "\r\n"
|
221
|
+
d << img + "\r\n"
|
222
|
+
d << "--#{boundary}--\r\n"
|
223
|
+
rq.body = d.join
|
224
|
+
end
|
225
|
+
|
226
|
+
if @debugLevel > 0
|
227
|
+
puts "Path: #{path}\n"
|
228
|
+
puts "Request headers:\n"
|
229
|
+
rq.each do | k, v |
|
230
|
+
puts "k: " + k + " :: " + v + "\n"
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
resp = @http.request(rq)
|
235
|
+
|
236
|
+
if @debugLevel > 0
|
237
|
+
puts "Response headers:\n"
|
238
|
+
resp.each do | k, v |
|
239
|
+
puts "k: " + k + " :: " + v + "\n"
|
240
|
+
end
|
241
|
+
puts "Code: " + resp.code + "/" + resp.code.class.to_s + "/\n"
|
242
|
+
end
|
243
|
+
|
244
|
+
goodCodes = api[:successCodes] || [200]
|
245
|
+
|
246
|
+
responseCode = resp.code.to_i
|
247
|
+
|
248
|
+
if goodCodes.include? responseCode
|
249
|
+
begin
|
250
|
+
rj = JSON.parse(resp.body)
|
251
|
+
rescue TypeError, JSON::ParserError => e
|
252
|
+
# On some requests that return "nothing" the server
|
253
|
+
# returns {} ... on others it literally returns nothing.
|
254
|
+
if (not resp.body) or resp.body.length == 0
|
255
|
+
rj = {}
|
256
|
+
else
|
257
|
+
# this isn't supposed to happen... server bug?
|
258
|
+
raise e
|
259
|
+
end
|
260
|
+
end
|
261
|
+
else
|
262
|
+
rj = { errorType: "HTTPError" }
|
263
|
+
rj[:code] = responseCode
|
264
|
+
rj[:reason] = resp.message
|
265
|
+
rj[:value] = "Server returned an HTTP error: #{resp.message}"
|
266
|
+
rj[:id] = url
|
267
|
+
if responseCode == 401 # XXX is there an HTTP constant for this?
|
268
|
+
emeth = NumerousAuthError
|
269
|
+
else
|
270
|
+
emeth = NumerousError
|
271
|
+
end
|
272
|
+
|
273
|
+
raise emeth.new(rj[:value],responseCode, rj)
|
274
|
+
|
275
|
+
end
|
276
|
+
|
277
|
+
return rj
|
278
|
+
end
|
279
|
+
|
280
|
+
# This is a special case ... a bit of a hack ... to determine
|
281
|
+
# the underlying (redirected-to) URL for metric photos. The issue
|
282
|
+
# is that sometimes we want to get at the no-auth-required actual
|
283
|
+
# image URL (vs the metric API endpoint for getting a photo)
|
284
|
+
#
|
285
|
+
# This does that by (unfortunately) getting the actual image and
|
286
|
+
# then using the r.url feature of requests library to get at what
|
287
|
+
# the final (actual/real) URL was.
|
288
|
+
|
289
|
+
def getRedirect(url)
|
290
|
+
rq = MethMap[:GET].new(url)
|
291
|
+
rq.basic_auth(@auth[:user], @auth[:password])
|
292
|
+
rq['user-agent'] = @agentString
|
293
|
+
|
294
|
+
resp = @http.request(rq)
|
295
|
+
return resp.header['Location']
|
296
|
+
end
|
297
|
+
|
298
|
+
|
299
|
+
# generic iterator for chunked APIs
|
300
|
+
def chunkedIterator(info, subs={}, block)
|
301
|
+
api = makeAPIcontext(info, :GET, subs)
|
302
|
+
list = []
|
303
|
+
nextURL = api[:basePath]
|
304
|
+
|
305
|
+
while nextURL
|
306
|
+
# get a chunk from the server
|
307
|
+
|
308
|
+
# XXX in the python version we caught various exceptions and
|
309
|
+
# attempted to translate them into something meaningful
|
310
|
+
# (e.g., if a metric got deleted while you were iterating)
|
311
|
+
# But here we're just letting the whatever-exceptions filter up
|
312
|
+
v = simpleAPI(api, url:nextURL)
|
313
|
+
|
314
|
+
list = v[api[:list]]
|
315
|
+
nextURL = v[api[:next]]
|
316
|
+
|
317
|
+
# hand them out
|
318
|
+
if list # can be nil for a variety of reasons
|
319
|
+
list.each { |i| block.call i }
|
320
|
+
end
|
321
|
+
end
|
322
|
+
return nil # the subclasses return (should return) their own self
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
|
327
|
+
class Numerous < NumerousClientInternals
|
328
|
+
|
329
|
+
# path info for the server-level APIs: create a metric, get user info, etc
|
330
|
+
|
331
|
+
APIInfo = {
|
332
|
+
# POST to this to create a metric
|
333
|
+
create: {
|
334
|
+
path: '/v1/metrics',
|
335
|
+
POST: { successCodes: [ 201 ] }
|
336
|
+
},
|
337
|
+
|
338
|
+
# GET a users metric collection
|
339
|
+
metricsCollection: {
|
340
|
+
path: '/v2/users/%{userId}/metrics',
|
341
|
+
defaults: { userId: 'me' },
|
342
|
+
GET: { next: 'nextURL', list: 'metrics' }
|
343
|
+
},
|
344
|
+
|
345
|
+
# subscriptions at the user level
|
346
|
+
subscriptions: {
|
347
|
+
path: '/v2/users/%{userId}/subscriptions',
|
348
|
+
defaults: { userId: 'me' },
|
349
|
+
GET: { next: 'nextURL', list: 'subscriptions' }
|
350
|
+
},
|
351
|
+
|
352
|
+
# user info
|
353
|
+
user: {
|
354
|
+
path: '/v1/users/%{userId}',
|
355
|
+
defaults: { userId: 'me' },
|
356
|
+
photo: { appendPath: '/photo', httpMethod: :POST, successCodes: [201] }
|
357
|
+
},
|
358
|
+
|
359
|
+
# the most-popular metrics list
|
360
|
+
popular: {
|
361
|
+
path: '/v1/metrics/popular?count=%{count}',
|
362
|
+
defaults: { count: 10 }
|
363
|
+
# no entry needed for GET because no special codes etc
|
364
|
+
}
|
365
|
+
}
|
366
|
+
|
367
|
+
|
368
|
+
# return User info (Default is yourself)
|
369
|
+
def user(userId:nil)
|
370
|
+
api = makeAPIcontext(APIInfo[:user], :GET, {userId: userId})
|
371
|
+
return simpleAPI(api)
|
372
|
+
end
|
373
|
+
|
374
|
+
# set the user's photo
|
375
|
+
# imageDataOrReadable is the raw binary image data OR
|
376
|
+
# an object with a read method (e.g., an open file)
|
377
|
+
# mimeType defaults to image/jpeg but you can specify as needed
|
378
|
+
#
|
379
|
+
# NOTE: The server enforces a size limit (I don't know it)
|
380
|
+
# and you will get an HTTP "Too Large" error if you exceed it
|
381
|
+
def userPhoto(imageDataOrReadable, mimeType:'image/jpeg')
|
382
|
+
api = makeAPIcontext(APIInfo[:user], :photo)
|
383
|
+
mpart = { :f => imageDataOrReadable, :mimeType => mimeType }
|
384
|
+
return simpleAPI(api, multipart: mpart)
|
385
|
+
end
|
386
|
+
|
387
|
+
# various iterators for invoking a block on various collections.
|
388
|
+
# The "return self" is convention for chaining though not clear how useful
|
389
|
+
|
390
|
+
# metrics: all metrics for the given user (default is your own)
|
391
|
+
def metrics(userId:nil, &block)
|
392
|
+
chunkedIterator(APIInfo[:metricsCollection], { userId: userId }, block)
|
393
|
+
return self
|
394
|
+
end
|
395
|
+
|
396
|
+
# subscriptions: all the subscriptions for the given user
|
397
|
+
def subscriptions(userId:nil, &block)
|
398
|
+
chunkedIterator(APIInfo[:subscriptions], { userId: userId }, block)
|
399
|
+
return self
|
400
|
+
end
|
401
|
+
|
402
|
+
|
403
|
+
|
404
|
+
# most popular metrics ... not an iterator
|
405
|
+
def mostPopular(count:nil)
|
406
|
+
api = makeAPIcontext(APIInfo[:popular], :GET, {count: count})
|
407
|
+
return simpleAPI(api)
|
408
|
+
end
|
409
|
+
|
410
|
+
# test/verify connectivity to the server
|
411
|
+
def ping
|
412
|
+
ignored = user()
|
413
|
+
return true # errors throw exceptions
|
414
|
+
end
|
415
|
+
|
416
|
+
def createMetric(label, value:nil, attrs:{})
|
417
|
+
api = makeAPIcontext(APIInfo[:create], :POST)
|
418
|
+
|
419
|
+
j = attrs.clone
|
420
|
+
j['label'] = label
|
421
|
+
if value
|
422
|
+
j['value'] = value
|
423
|
+
end
|
424
|
+
v = simpleAPI(api, jdict:j)
|
425
|
+
return metric(v['id'])
|
426
|
+
end
|
427
|
+
|
428
|
+
# instantiate a metric object to access a numerous metric
|
429
|
+
# -- this is NOT creating a metric on the server; this is how you
|
430
|
+
# access a metric that already exists
|
431
|
+
def metric(id)
|
432
|
+
return NumerousMetric.new(id, self)
|
433
|
+
end
|
434
|
+
|
435
|
+
end
|
436
|
+
|
437
|
+
|
438
|
+
class NumerousMetric < NumerousClientInternals
|
439
|
+
def initialize(id, nr)
|
440
|
+
@id = id
|
441
|
+
@nr = nr
|
442
|
+
end
|
443
|
+
attr_accessor :id
|
444
|
+
|
445
|
+
# could have just made an accessor, but I prefer it this way for this one
|
446
|
+
def getServer()
|
447
|
+
return @nr
|
448
|
+
end
|
449
|
+
|
450
|
+
APIInfo = {
|
451
|
+
# read/update/delete a metric
|
452
|
+
metric: {
|
453
|
+
path: '/v1/metrics/%{metricId}' ,
|
454
|
+
# no entries needed for GET/PUT because no special codes etc
|
455
|
+
DELETE: {
|
456
|
+
successCodes: [ 204 ]
|
457
|
+
}
|
458
|
+
},
|
459
|
+
|
460
|
+
# you can GET or POST the events collection
|
461
|
+
events: {
|
462
|
+
path: '/v1/metrics/%{metricId}/events',
|
463
|
+
GET: {
|
464
|
+
next: 'nextURL',
|
465
|
+
list: 'events'
|
466
|
+
},
|
467
|
+
POST: {
|
468
|
+
successCodes: [ 201 ]
|
469
|
+
}
|
470
|
+
},
|
471
|
+
# and you can GET or DELETE an individual event
|
472
|
+
# (no entry made for GET because all standard parameters on that one)
|
473
|
+
event: {
|
474
|
+
path: '/v1/metrics/%{metricId}/events/%{eventID}',
|
475
|
+
DELETE: {
|
476
|
+
successCodes: [ 204 ] # No Content is the expected return
|
477
|
+
}
|
478
|
+
},
|
479
|
+
|
480
|
+
# GET the stream collection
|
481
|
+
stream: {
|
482
|
+
path: '/v2/metrics/%{metricId}/stream',
|
483
|
+
GET: {
|
484
|
+
next: 'next',
|
485
|
+
list: 'items'
|
486
|
+
}
|
487
|
+
},
|
488
|
+
|
489
|
+
# you can GET or POST the interactions collection
|
490
|
+
interactions: {
|
491
|
+
path: '/v2/metrics/%{metricId}/interactions',
|
492
|
+
GET: {
|
493
|
+
next: 'nextURL',
|
494
|
+
list: 'interactions'
|
495
|
+
},
|
496
|
+
POST: {
|
497
|
+
successCodes: [ 201 ]
|
498
|
+
|
499
|
+
}
|
500
|
+
},
|
501
|
+
|
502
|
+
# and you can GET or DELETE an individual interaction
|
503
|
+
interaction: {
|
504
|
+
path: '/v2/metrics/%{metricId}/interactions/%{item}',
|
505
|
+
DELETE: {
|
506
|
+
successCodes: [ 204 ] # No Content is the expected return
|
507
|
+
}
|
508
|
+
},
|
509
|
+
|
510
|
+
# subscriptions collection on a metric
|
511
|
+
subscriptions: {
|
512
|
+
path: '/v2/metrics/%{metricId}/subscriptions',
|
513
|
+
GET: {
|
514
|
+
next: 'nextURL',
|
515
|
+
list: 'subscriptions'
|
516
|
+
}
|
517
|
+
},
|
518
|
+
|
519
|
+
# subscriptions on a particular metric
|
520
|
+
subscription: {
|
521
|
+
path: '/v1/metrics/%{metricId}/subscriptions/%{userId}',
|
522
|
+
defaults: {
|
523
|
+
userId: 'me' # default userId for yourself ("me")
|
524
|
+
},
|
525
|
+
PUT: {
|
526
|
+
successCodes: [ 200, 201 ]
|
527
|
+
}
|
528
|
+
},
|
529
|
+
|
530
|
+
photo: {
|
531
|
+
path: '/v1/metrics/%{metricId}/photo',
|
532
|
+
POST: {
|
533
|
+
successCodes: [ 201 ]
|
534
|
+
},
|
535
|
+
DELETE: {
|
536
|
+
successCodes: [ 204 ]
|
537
|
+
}
|
538
|
+
}
|
539
|
+
|
540
|
+
}
|
541
|
+
|
542
|
+
# small wrapper to always supply the metricId substitution
|
543
|
+
def getAPI(a, mx, args={})
|
544
|
+
return @nr.makeAPIcontext(APIInfo[a], mx, args.merge({ metricId: @id }))
|
545
|
+
end
|
546
|
+
private :getAPI
|
547
|
+
|
548
|
+
|
549
|
+
def read(dictionary: false)
|
550
|
+
api = getAPI(:metric, :GET)
|
551
|
+
v = @nr.simpleAPI(api)
|
552
|
+
return (if dictionary then v else v['value'] end)
|
553
|
+
end
|
554
|
+
|
555
|
+
# "Validate" a metric object.
|
556
|
+
# There really is no way to do this in any way that carries much weight.
|
557
|
+
# However, if a user gives you a metricId and you'd like to know if
|
558
|
+
# that actually IS a metricId, this might be useful. Realize that
|
559
|
+
# even a valid metric can be deleted out from under and become invalid.
|
560
|
+
#
|
561
|
+
# Reads the metric, catches the specific exceptions that occur for
|
562
|
+
# invalid metric IDs, and returns True/False. Other exceptions mean
|
563
|
+
# something else went awry (server down, bad authentication, etc).
|
564
|
+
def validate
|
565
|
+
begin
|
566
|
+
ignored = read()
|
567
|
+
return true
|
568
|
+
rescue NumerousError => e
|
569
|
+
# bad request (400) is a completely bogus metric ID whereas
|
570
|
+
# not found (404) is a well-formed ID that simply does not exist
|
571
|
+
if e.code == 400 or e.code == 404
|
572
|
+
return false
|
573
|
+
else # anything else is a "real" error; figure out yourself
|
574
|
+
raise e
|
575
|
+
end
|
576
|
+
end
|
577
|
+
return false # this never happens
|
578
|
+
end
|
579
|
+
|
580
|
+
|
581
|
+
# define the events, stream, interactions, and subscriptions methods
|
582
|
+
# All have same pattern so we use some of Ruby's awesome meta hackery
|
583
|
+
%w(events stream interactions subscriptions).each do |w|
|
584
|
+
define_method(w) do | &block |
|
585
|
+
@nr.chunkedIterator(APIInfo[w.to_sym], {metricId:@id}, block)
|
586
|
+
return self
|
587
|
+
end
|
588
|
+
end
|
589
|
+
|
590
|
+
# read a single event or single interaction
|
591
|
+
%w(event interaction).each do |w|
|
592
|
+
define_method(w) do | evId |
|
593
|
+
api = getAPI(w.to_sym, :GET, {eventID:evId})
|
594
|
+
return @nr.simpleAPI(api)
|
595
|
+
end
|
596
|
+
end
|
597
|
+
|
598
|
+
|
599
|
+
# This is an individual subscription -- namely, yours.
|
600
|
+
# normal users can never see anything other than their own
|
601
|
+
# subscription so there is really no point in ever supplying
|
602
|
+
# the userId parameter (the default is all you can ever use)
|
603
|
+
def subscription(userId=nil)
|
604
|
+
api = getAPI(:subscription, :GET, {userId: userId})
|
605
|
+
return @nr.simpleAPI(api)
|
606
|
+
end
|
607
|
+
|
608
|
+
# Subscribe to a metric. See the API docs for what should be
|
609
|
+
# in the dict. This function will fetch the current parameters
|
610
|
+
# and update them with the ones you supply (because the server
|
611
|
+
# does not like you supplying an incomplete dictionary here).
|
612
|
+
# While in some cases this might be a bit of extra overhead
|
613
|
+
# it doesn't really matter because how often do you do this...
|
614
|
+
# You can, however, stop that with overwriteAll=True
|
615
|
+
#
|
616
|
+
# NOTE that you really can only subscribe yourself, so there
|
617
|
+
# really isn't much point in specifying a userId
|
618
|
+
def subscribe(dict, userId:nil, overwriteAll:False)
|
619
|
+
if overwriteAll
|
620
|
+
params = {}
|
621
|
+
else
|
622
|
+
params = subscription(userId)
|
623
|
+
end
|
624
|
+
|
625
|
+
dict.each { |k, v| params[k] = v }
|
626
|
+
|
627
|
+
api = getAPI(:subscription, :PUT, { userId: userId })
|
628
|
+
return @nr.simpleAPI(api, jdict:params)
|
629
|
+
end
|
630
|
+
|
631
|
+
# write a value to a metric.
|
632
|
+
#
|
633
|
+
# onlyIf=true sends the "only if it changed" feature of the NumerousAPI.
|
634
|
+
# -- throws NumerousMetricConflictError if no change
|
635
|
+
# add=true sends the "action: ADD" (the value is added to the metric)
|
636
|
+
# dictionary=true returns the full dictionary results.
|
637
|
+
def write(newval, onlyIf:false, add:false, dictionary:false)
|
638
|
+
j = { 'value' => newval }
|
639
|
+
if onlyIf
|
640
|
+
j['onlyIfChanged'] = true
|
641
|
+
end
|
642
|
+
if add
|
643
|
+
j['action'] = 'ADD'
|
644
|
+
end
|
645
|
+
|
646
|
+
api = getAPI(:events, :POST)
|
647
|
+
begin
|
648
|
+
v = @nr.simpleAPI(api, jdict:j)
|
649
|
+
|
650
|
+
rescue NumerousError => e
|
651
|
+
# if onlyIf was specified and the error is "conflict"
|
652
|
+
# (meaning: no change), raise ConflictError specifically
|
653
|
+
if onlyIf and e.code == 409
|
654
|
+
raise NumerousMetricConflictError.new(e.details, 0, "No Change")
|
655
|
+
else
|
656
|
+
raise e # never mind, plain NumerousError is fine
|
657
|
+
end
|
658
|
+
end
|
659
|
+
|
660
|
+
return (if dictionary then v else v['value'] end)
|
661
|
+
end
|
662
|
+
|
663
|
+
def update(dict, overwriteAll:false)
|
664
|
+
newParams = (if overwriteAll then {} else read(dictionary:true) end)
|
665
|
+
dict.each { |k, v| newParams[k] = v }
|
666
|
+
|
667
|
+
api = getAPI(:metric, :PUT)
|
668
|
+
return @nr.simpleAPI(api, jdict:newParams)
|
669
|
+
end
|
670
|
+
|
671
|
+
# common code for writing interactions
|
672
|
+
def writeInteraction(dict)
|
673
|
+
api = getAPI(:interactions, :POST)
|
674
|
+
v = @nr.simpleAPI(api, jdict:dict)
|
675
|
+
return v['id']
|
676
|
+
end
|
677
|
+
private :writeInteraction
|
678
|
+
|
679
|
+
#
|
680
|
+
# "Like" a metric
|
681
|
+
#
|
682
|
+
def like
|
683
|
+
# a like is written as an interaction
|
684
|
+
return writeInteraction({ 'kind' => 'like' })
|
685
|
+
end
|
686
|
+
|
687
|
+
#
|
688
|
+
# Write an error to a metric
|
689
|
+
#
|
690
|
+
def sendError(errText)
|
691
|
+
# an error is written as an interaction thusly:
|
692
|
+
# (commentBody is used for the error text)
|
693
|
+
j = { 'kind' => 'error' , 'commentBody' => errText }
|
694
|
+
return writeInteraction(j)
|
695
|
+
end
|
696
|
+
|
697
|
+
#
|
698
|
+
# Simply comment on a metric
|
699
|
+
#
|
700
|
+
def comment(ctext)
|
701
|
+
j = { 'kind' => 'comment' , 'commentBody' => ctext }
|
702
|
+
return writeInteraction(j)
|
703
|
+
end
|
704
|
+
|
705
|
+
# set the background image for a metric
|
706
|
+
# imageDataOrReadable is the raw binary image data OR
|
707
|
+
# an object with a read method (e.g., an open file)
|
708
|
+
# mimeType defaults to image/jpeg but you can specify as needed
|
709
|
+
#
|
710
|
+
# NOTE: The server enforces a size limit (I don't know it)
|
711
|
+
# and you will get an HTTP "Too Large" error if you exceed it
|
712
|
+
def photo(imageDataOrReadable, mimeType:'image/jpeg')
|
713
|
+
api = getAPI(:photo, :POST)
|
714
|
+
mpart = { :f => imageDataOrReadable, :mimeType => mimeType }
|
715
|
+
return @nr.simpleAPI(api, multipart: mpart)
|
716
|
+
end
|
717
|
+
|
718
|
+
# various deletion methods.
|
719
|
+
# NOTE: If you try to delete something that isn't there, you will
|
720
|
+
# see an exception but the "error" code will be 200/OK.
|
721
|
+
# Semantically, the delete "works" in that case (i.e., the invariant
|
722
|
+
# that the thing should be gone after this invocation is, in fact, true).
|
723
|
+
# Nevertheless, I let the exception come through to you in case you
|
724
|
+
# want to know if this happened. This note applies to all deletion methods.
|
725
|
+
|
726
|
+
def photoDelete
|
727
|
+
api = getAPI(:photo, :DELETE)
|
728
|
+
v = @nr.simpleAPI(api)
|
729
|
+
return nil
|
730
|
+
end
|
731
|
+
|
732
|
+
def eventDelete(evID)
|
733
|
+
api = getAPI(:event, :DELETE, {eventID:evID})
|
734
|
+
v = @nr.simpleAPI(api)
|
735
|
+
return nil
|
736
|
+
end
|
737
|
+
|
738
|
+
def interactionDelete(interID)
|
739
|
+
api = getAPI(:interaction, :DELETE, {item:interID})
|
740
|
+
v = @nr.simpleAPI(api)
|
741
|
+
return nil
|
742
|
+
end
|
743
|
+
|
744
|
+
# the photoURL returned by the server in the metrics parameters
|
745
|
+
# still requires authentication to fetch (it then redirects to the "real"
|
746
|
+
# static photo URL). This function goes one level deeper and
|
747
|
+
# returns you an actual, publicly-fetchable, photo URL. I have not
|
748
|
+
# yet figured out how to tease this out without doing the full-on GET
|
749
|
+
# (using HEAD on a photo is rejected by the server)
|
750
|
+
def photoURL
|
751
|
+
v = read(dictionary:true)
|
752
|
+
begin
|
753
|
+
phurl = v.fetch('photoURL')
|
754
|
+
return @nr.getRedirect(phurl)
|
755
|
+
rescue KeyError
|
756
|
+
return nil
|
757
|
+
end
|
758
|
+
# never reached
|
759
|
+
return nil
|
760
|
+
end
|
761
|
+
|
762
|
+
# some convenience functions ... but all these do is query the
|
763
|
+
# server (read the metric) and return the given field... you could
|
764
|
+
# do the very same yourself. So I only implemented a few useful ones.
|
765
|
+
def label
|
766
|
+
v = read(dictionary:true)
|
767
|
+
return v['label']
|
768
|
+
end
|
769
|
+
|
770
|
+
def webURL
|
771
|
+
v = read(dictionary:true)
|
772
|
+
return v['links']['web']
|
773
|
+
end
|
774
|
+
|
775
|
+
# be 100% sure, because this cannot be undone. Deletes a metric
|
776
|
+
def crushKillDestroy
|
777
|
+
api = getAPI(:metric, :DELETE)
|
778
|
+
v = @nr.simpleAPI(api)
|
779
|
+
return nil
|
780
|
+
end
|
781
|
+
|
782
|
+
end
|
783
|
+
|
784
|
+
|
785
|
+
|
metadata
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: numerousapp
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.9.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Neil Webber
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-12-23 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: Classes implementing the NumerousApp REST APIs for metrics. Requires
|
14
|
+
Ruby 2.x
|
15
|
+
email: nw@neilwebber.com
|
16
|
+
executables: []
|
17
|
+
extensions: []
|
18
|
+
extra_rdoc_files: []
|
19
|
+
files:
|
20
|
+
- lib/numerousapp.rb
|
21
|
+
homepage: https://github.com/outofmbufs/numeruby
|
22
|
+
licenses:
|
23
|
+
- MIT
|
24
|
+
metadata: {}
|
25
|
+
post_install_message:
|
26
|
+
rdoc_options: []
|
27
|
+
require_paths:
|
28
|
+
- lib
|
29
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '>='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 2.0.0
|
34
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
35
|
+
requirements:
|
36
|
+
- - '>='
|
37
|
+
- !ruby/object:Gem::Version
|
38
|
+
version: '0'
|
39
|
+
requirements: []
|
40
|
+
rubyforge_project:
|
41
|
+
rubygems_version: 2.0.14
|
42
|
+
signing_key:
|
43
|
+
specification_version: 4
|
44
|
+
summary: NumerousApp API
|
45
|
+
test_files: []
|