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.
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: []