numerousapp 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/numerousapp.rb +785 -0
  3. 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
@@ -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: []