numerousapp 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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: []
|