npolar-api-client-ruby 0.2.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.
data/README.md ADDED
@@ -0,0 +1,57 @@
1
+ npolar-api-client-ruby
2
+ ======================
3
+ UNSTABLE Ruby client for https://api.npolar.no, based on [Typhoeus](https://github.com/typhoeus/typhoeus)
4
+
5
+ ## Features
6
+
7
+ * Handles POST of large JSON Arrays
8
+ * Parallel requests
9
+ * Mimicks well-known curl commands
10
+ * Automatic authentication on write operations
11
+ * Automatic Content-Type, Accept, and other headers
12
+
13
+ ## npolar-api (command-line tool)
14
+ ```
15
+ npolar-api [options] [https://api.npolar.no]/endpoint
16
+
17
+ npolar-api /schema
18
+ npolar-api -XPOST /endpoint --data=/file.json
19
+ npolar-api -XPOST /endpoint --data='{"title":"Title"}'
20
+ npolar-api -XPUT --headers http://admin:password@localhost:5984/testdb
21
+ npolar-api -XPUT --headers http://admin:password@localhost:5984/testdb/test1
22
+ npolar-api -XDELETE /endpoint/id
23
+
24
+ npolar-api is built on top of Typhoeus/libcurl.
25
+ For more information and source: https://github.com/npolar/npolar-api-ruby-client
26
+
27
+ Options:
28
+ --auth Force authorization
29
+ -d, --data=data Data (request body) for POST and PUT
30
+ --debug Debug (alias for --level=debug
31
+ -l, --level=level Log level
32
+ -X, --method=method HTTP method, GET is default
33
+ -H, --header=header Add HTTP request header
34
+ --ids=ids URI that returns identifiers
35
+ --join Use --join with --ids to join documents into a JSON array
36
+ -c, --concurrency=number Concurrency (max)
37
+ -s, --slice=number Slice size on POST
38
+ -i, --headers Show HTTP response headers
39
+ -v, --verbose Verbose
40
+
41
+ ```
42
+
43
+ ## Install
44
+
45
+ gem install # not-yet-released
46
+
47
+ Gemfile:
48
+ gem "npolar-api-client-ruby"
49
+
50
+
51
+ ## Authentication
52
+
53
+ Set the following environmental variables for automatic authentication
54
+ ```
55
+ NPOLAR_API_USERNAME=username
56
+ NPOLAR_API_PASSWORD=********
57
+ ```
data/bin/npolar-api ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+
4
+ # Ruby-based command line client for http://api.npolar.no
5
+ #
6
+ # For more information: $ ./bin/npolar_api --help
7
+ # or https://github.com/npolar/npolar-api-client/blob/master/README.md
8
+
9
+ require "bundler/setup"
10
+ require_relative "../lib/npolar/api/client"
11
+ require_relative "../lib/npolar/api/client/npolar_api_command"
12
+
13
+ Npolar::Api::Client::NpolarApiCommand.run
@@ -0,0 +1,19 @@
1
+ require "yajl/json_gem"
2
+ require "hashie"
3
+ require "typhoeus"
4
+ require "forwardable"
5
+ require "uri"
6
+
7
+ module Npolar
8
+ module Api
9
+ module Client
10
+
11
+ VERSION = "0.2.0"
12
+
13
+ USER_AGENT = "npolar-api-client-ruby-#{VERSION}/Typhoeus-#{Typhoeus::VERSION}/libcurl-#{`curl --version`.chomp.split(" ")[1]}"
14
+
15
+ end
16
+ end
17
+ end
18
+
19
+ require_relative "client/json_api_client"
@@ -0,0 +1,567 @@
1
+ # encoding: utf-8
2
+ require "uri"
3
+ require "typhoeus"
4
+
5
+ class ::Typhoeus::Response
6
+ alias :status :code
7
+
8
+ def uri
9
+ URI.parse(options[:effective_url])
10
+ end
11
+
12
+ end
13
+
14
+ class ::Typhoeus::Request
15
+
16
+ def uri
17
+ URI.parse(url)
18
+ end
19
+
20
+ def verb
21
+ options[:method].to_s.upcase
22
+ end
23
+ alias :http_method :verb
24
+ alias :request_method :verb
25
+
26
+ end
27
+
28
+ module Npolar::Api::Client
29
+
30
+ # Ruby client for https://api.npolar.no, based on Typhoeus and libcurl
31
+ # https://github.com/typhoeus/typhoeus
32
+ class JsonApiClient
33
+
34
+ VERSION = "0.10.pre"
35
+
36
+ class << self
37
+ attr_accessor :key
38
+ end
39
+ attr_accessor :model, :log, :authorization, :concurrency, :slice, :param, :header
40
+ attr_reader :uri, :responses, :response, :options
41
+
42
+ extend ::Forwardable
43
+ def_delegators :uri, :scheme, :host, :port, :path
44
+
45
+ BASE = "https://api.npolar.no"
46
+
47
+ HEADER = { "User-Agent" => Npolar::Api::Client::USER_AGENT,
48
+ "Content-Type" => "application/json",
49
+ "Accept" => "application/json",
50
+ "Accept-Charset" => "UTF-8",
51
+ "Accept-Encoding" => "gzip,deflate",
52
+ "Connection" => "keep-alive"
53
+ }
54
+ # Typhoeus options => RENAME
55
+ OPTIONS = { :headers => HEADER,
56
+ :timeout => nil, # 600 seconds or nil for never
57
+ :forbid_reuse => true
58
+ }
59
+
60
+ # New client
61
+ # @param [String | URI] base Base URI for all requests
62
+ # @param [Hash] options (for Typhoeus)
63
+ def initialize(base=BASE, options=OPTIONS)
64
+ # Prepend https://api.npolar.no if base is relative (like /service)
65
+ if base =~ /^\//
66
+ path = base
67
+ base = BASE+path
68
+ end
69
+ @base = base
70
+ unless base.is_a? URI
71
+ @uri = URI.parse(base)
72
+ end
73
+ @options = options
74
+ init
75
+ end
76
+
77
+ def init
78
+ @model = Hashie::Mash.new
79
+ @log = ENV["NPOLAR_ENV"] == "test" ? ::Logger.new("/dev/null") : ::Logger.new(STDERR)
80
+ @concurrency = 5
81
+ @slice = 1000
82
+ @param={}
83
+ @header={}
84
+ end
85
+
86
+ # All documents
87
+ def all
88
+ mash = get_body("_feed", {:fields=>"*"})
89
+ unless mash.key? "feed"
90
+ raise "No feed returned"
91
+ end
92
+ mash.feed.entries
93
+ end
94
+ alias :feed :all
95
+
96
+ # Base URI (without trailing slash)
97
+ def base
98
+ unless @base.nil?
99
+ @base.gsub(/\/$/, "")
100
+ end
101
+ end
102
+
103
+ # DELETE
104
+ #
105
+ def delete(path=nil, param={}, header={})
106
+ if param.key? "ids"
107
+ delete_ids(uri, param["ids"])
108
+ else
109
+ execute(
110
+ request(path, :delete, nil, param, header)
111
+ )
112
+ end
113
+ end
114
+
115
+ def delete_ids(endpoint, ids)
116
+ delete_uris(self.class.uris_from_ids(endpoint, ids))
117
+ end
118
+
119
+ def delete_uris(uris)
120
+ @responses=[]
121
+ multi_request("DELETE", uris, nil, param, header).run
122
+ responses
123
+ end
124
+
125
+ # Request header Hash
126
+ def header
127
+ options[:headers]
128
+ # merge!
129
+ end
130
+ alias :headers :header
131
+
132
+ def http_method
133
+ @method
134
+ end
135
+ alias :verb :http_method
136
+
137
+ # Validation errors
138
+ # @return [Array]
139
+ def errors(document_or_id)
140
+ @errors ||= model.merge(document_or_id).errors
141
+ end
142
+
143
+ # deprecated
144
+ def get_body(uri, param={})
145
+ @param = param
146
+ response = get(uri)
147
+ unless response.success?
148
+ raise "Could not GET #{uri} status: #{response.code}"
149
+ end
150
+
151
+ begin
152
+ body = JSON.parse(response.body)
153
+ if body.is_a? Hash
154
+
155
+ if model? and not body.key? "feed"
156
+ body = model.class.new(body)
157
+ else
158
+ body = Hashie::Mash.new(body)
159
+ end
160
+ end
161
+
162
+ rescue
163
+ body = response.body
164
+ end
165
+
166
+ body
167
+
168
+ #if response.headers["Content-Type"] =~ /application\/json/
169
+ # #if model?
170
+ # JSON.parse(body)
171
+ # #
172
+ # #model
173
+ #else
174
+ # body
175
+ #end
176
+
177
+ end
178
+
179
+ # GET
180
+ def get(path=nil)
181
+ if param.key? "ids"
182
+ get_ids(uri, param["ids"])
183
+ else
184
+ request = request(path, :get, nil, param, header)
185
+
186
+ request.on_success do |response|
187
+ if response.headers["Content-Type"] =~ /application\/json/
188
+ parsed = JSON.parse(response.body)
189
+ if model?
190
+ begin
191
+ # hmm => will loose model!
192
+ @modelwas = model
193
+ @model = model.class.new(parsed)
194
+ @mash = model
195
+ rescue
196
+ @model = @modelwas
197
+ # Parsing only for JSON objects
198
+ end
199
+ end
200
+ end
201
+ end
202
+
203
+ execute(request)
204
+ end
205
+ end
206
+
207
+ def get_ids(endpoint, ids)
208
+ get_uris(self.class.uris_from_ids(endpoint, ids))
209
+ end
210
+
211
+ def get_uris(uris)
212
+ @responses=[]
213
+ multi_request("GET", uris).run
214
+ responses
215
+ ## set on success =>
216
+ #json = responses.select {|r| r.success? and r.headers["Content-Type"] =~ /application\/(\w+[+])?json/ }
217
+ #if json.size == responses.size
218
+ # responses.map {|r| r.body }
219
+ #else
220
+ # raise "Failed "
221
+ #end
222
+ end
223
+ alias :multi_get :get_uris
224
+
225
+ # HEAD
226
+ def head(path=nil, param={}, header={})
227
+ execute(request(path, :head, nil, param, header))
228
+ end
229
+
230
+ # All ids
231
+ def ids
232
+ get_body("_ids").ids
233
+ end
234
+
235
+ # All invalid documents
236
+ def invalid
237
+ valid(false)
238
+ end
239
+
240
+ # Model?
241
+ def model?
242
+ not @model.nil?
243
+ end
244
+
245
+ # POST
246
+ # @param [Array, Hash, String] body
247
+ def post(body, path=nil, param={}, header={})
248
+ if header["Content-Type"] =~ /application\/(\w+[+])?json/
249
+ chunk_save(path, "POST", body, param, header)
250
+ else
251
+ execute(
252
+ request(path, :post, body, param, header)
253
+ )
254
+ end
255
+ end
256
+
257
+ # PUT
258
+ def put(body, path=nil, param={}, header={})
259
+ execute(
260
+ request(path, :put, body, param, header)
261
+ )
262
+ end
263
+
264
+ def status
265
+ response.code
266
+ end
267
+
268
+ def uris
269
+ ids.map {|id| base+"/"+id }
270
+ end
271
+
272
+ # All valid documents
273
+ def valid(condition=true)
274
+ all.select {|d| condition == valid?(d) }.map {|d| model.class.new(d)}
275
+ end
276
+
277
+ # Valid?
278
+ def valid?(document_or_id)
279
+ # FIXME Hashie::Mash will always respond to #valid?
280
+ if not model? or not model.respond_to?(:valid?)
281
+ return true
282
+ end
283
+
284
+ validator = model.class.new(document_or_id)
285
+
286
+ # Return true if validator is a Hash with errors key !
287
+ # FIXME Hashie::Mash will always respond to #valid?
288
+ if validator.key? :valid? or validator.key? :errors
289
+ return true
290
+ end
291
+
292
+ valid = validator.valid?
293
+
294
+ if validator.errors.nil?
295
+ return true
296
+ end
297
+
298
+ @errors = validator.errors # store to avoid revalidating
299
+ valid = case valid
300
+ when true, nil
301
+ true
302
+ when false
303
+ false
304
+ end
305
+ valid
306
+ end
307
+
308
+ def username
309
+ # export NPOLAR_HTTP_USERNAME=http_username
310
+ @username ||= ENV["NPOLAR_API_USERNAME"]
311
+ end
312
+
313
+ def username=(username)
314
+ @username=username
315
+ end
316
+
317
+ def password
318
+ # export NPOLAR_HTTP_PASSWORD=http_password
319
+ @password ||= ENV["NPOLAR_API_PASSWORD"]
320
+ end
321
+
322
+ def password=(password)
323
+ @password=password
324
+ end
325
+
326
+ def execute(request=nil)
327
+ log.debug log_message(request)
328
+ @response = request.run
329
+ end
330
+
331
+ # @return []
332
+ def request(path=nil, method=:get, body=nil, params={}, headers={})
333
+
334
+ if path =~ /^http(s)?[:]\/\//
335
+ # Absolute URI
336
+ uri = path
337
+
338
+ elsif path.nil?
339
+ # Use base URI if path is nil
340
+ uri = base
341
+
342
+ elsif path =~ /^\/\w+/
343
+ # Support /relative URIs by prepending base
344
+ uri = base+path
345
+
346
+ elsif path =~ /^\w+/ and base =~ /^http(s)?[:]\/\//
347
+ uri = base+"/"+path
348
+ else
349
+ # Invalid URI
350
+ raise ArgumentError, "Path is invalid: #{path}"
351
+ end
352
+
353
+ unless uri.is_a? URI
354
+ uri = URI.parse(uri)
355
+ end
356
+
357
+ @uri = uri
358
+ @param = param
359
+ @header = headers
360
+ method = method.downcase.to_sym
361
+
362
+ context = { method: method,
363
+ body: body,
364
+ params: params,
365
+ headers: headers
366
+ }
367
+ if true == authorization or [:delete, :post, :put].include? method
368
+ context[:userpwd] = "#{username}:#{password}"
369
+ end
370
+
371
+ request = Typhoeus::Request.new(uri.to_s, context)
372
+
373
+ request.on_complete do |response|
374
+ on_complete.call(response)
375
+ end
376
+
377
+ request.on_failure do |response|
378
+ on_failure.call(response)
379
+ end
380
+
381
+ request.on_success do |response|
382
+ on_success.call(response)
383
+ end
384
+
385
+ @request = request
386
+
387
+ request
388
+
389
+ end
390
+
391
+ def on_failure
392
+ @on_failure ||= lambda {|response|
393
+ if response.code == 0
394
+ # No response, something's wrong.
395
+ log.error "#{request.verb} #{request.uri.path} failed with message: #{response.return_message}"
396
+ elsif response.timed_out?
397
+ log.error "#{request.verb} #{request.uri.path} timed out in #{response.total_time} seconds"
398
+ else
399
+ log.error log_message(response)
400
+ end
401
+ }
402
+ end
403
+
404
+ def on_complete
405
+ @on_complete ||= lambda {|response|} #noop
406
+ end
407
+
408
+ def on_success
409
+ @on_success ||= lambda {|response|
410
+ log.info log_message(response)
411
+ }
412
+ end
413
+
414
+ #def on_complete=(on_complete_lambda)
415
+ # if @on_complete.nil?
416
+ # @on_complete = []
417
+ # end
418
+ # @on_complete << on_complete_lambda
419
+ #end
420
+
421
+ protected
422
+
423
+ # @return [Array] ids
424
+ def self.fetch_ids(uri)
425
+ client = self.new(uri)
426
+ client.model = nil
427
+
428
+ response = client.get
429
+ #if 200 == response.code
430
+ #
431
+ #end
432
+
433
+ idlist = JSON.parse(response.body)
434
+
435
+ if idlist.key? "feed" and idlist["feed"].key? "entries"
436
+
437
+ ids = idlist["feed"]["entries"].map {|d|
438
+ d["id"]
439
+ }
440
+
441
+ elsif idlist.key? "ids"
442
+
443
+ ids = idlist["ids"]
444
+
445
+ else
446
+ raise "Cannot fetch ids"
447
+ end
448
+ end
449
+
450
+ # @return [Array] URIs
451
+ def self.uris_from_ids(base, ids)
452
+
453
+ unless ids.is_a? Array
454
+ if ids =~ /^http(s)?[:]\/\//
455
+ ids = fetch_ids(ids)
456
+ else
457
+ raise "Can only fetch ids via HTTP"
458
+ end
459
+ end
460
+
461
+ unless base.is_a? URI
462
+ base = URI.parse(base)
463
+ end
464
+
465
+ ids.map {|id|
466
+ path = base.path+"/"+id
467
+ uri = base.dup
468
+ uri.path = path
469
+ uri
470
+ }
471
+
472
+ end
473
+
474
+ def hydra
475
+ @hydra ||= Typhoeus::Hydra.new(max_concurrency: concurrency)
476
+ end
477
+
478
+ # Prepare and queue a multi request
479
+ #
480
+ # @return [#run]
481
+ def multi_request(method, paths, body=nil, param=nil, header=nil)
482
+ @multi = true
483
+
484
+ # Response storage, if not already set
485
+ if @responses.nil?
486
+ @responses = []
487
+ end
488
+
489
+ # Handle one or many paths
490
+ if paths.is_a? String or paths.is_a? URI
491
+ paths = [paths]
492
+ end
493
+
494
+ # Handle (URI) objects
495
+ paths = paths.map {|p| p.to_s }
496
+
497
+ log.debug "Queueing multi-#{method} requests, concurrency: #{concurrency}, path(s): #{ paths.size == 1 ? paths[0]: paths.size }"
498
+
499
+ paths.each do | path |
500
+
501
+ multi_request = request(path, method.downcase.to_sym, body, param, header)
502
+ multi_request.on_complete do | response |
503
+ log.debug "Multi-#{method} [#{paths.size}]: "+log_message(response)
504
+ @responses << response
505
+ end
506
+ hydra.queue(multi_request)
507
+ end
508
+ hydra
509
+ end
510
+ alias :queue :multi_request
511
+
512
+ # Slice Array of documents into chunks of #slice size and queue up for POST or PUT
513
+ # @return [Array] responses
514
+ def chunk_save(path=nil, method="POST", docs, param, header)
515
+ @multi = true
516
+
517
+ if path.nil?
518
+ path = uri
519
+ end
520
+
521
+ unless docs.is_a? Array
522
+ docs = JSON.parse(docs)
523
+ end
524
+
525
+ if docs.is_a? Hash
526
+ docs = [docs]
527
+ end
528
+ if slice > docs.size
529
+ log.debug "Slicing #{docs.size} documents into #{(docs.size.to_f/slice.to_f).round} chunks of #{slice} each"
530
+ end
531
+ docs.each_slice(slice) do | chunk |
532
+ queue(method, path, chunk.to_json, param, header)
533
+ end
534
+ hydra.run
535
+
536
+ # @todo => on complete
537
+ successes = @responses.select {|r| (200..299).include? r.code }
538
+ if successes.size > 0
539
+
540
+ if docs.size < slice
541
+ log.info "Saved #{docs.size} document(s) using #{@responses.size} #{method} request(s). Concurrency: #{concurrency}"
542
+ else
543
+ log.info "Saved #{docs.size} documents, sliced into chunks of #{slice} using #{@responses.size} #{method} requests. Concurrency: #{concurrency}"
544
+ end
545
+ else
546
+ failures = @responses.reject {|r| (200..299).include? r.code }
547
+ log.debug "#chunk_save error in #{failures.size}/#{responses.size} requests"
548
+ end
549
+
550
+ @responses
551
+ end
552
+
553
+
554
+ def log_message(r)
555
+ if r.is_a? Typhoeus::Request
556
+ request = r
557
+ "#{request.http_method} #{scheme}://#{host}:#{port}#{path} [#{self.class.name}] #{param} #{header}"
558
+ else
559
+ response = r
560
+
561
+ "#{response.code} #{response.request.http_method} #{response.request.url} [#{self.class.name}] #{response.total_time} #{response.body.bytesize} #{response.body[0..255]}"
562
+ end
563
+ end
564
+
565
+ end
566
+
567
+ end