gini-api 0.9.2

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.
@@ -0,0 +1,175 @@
1
+ require 'oauth2'
2
+
3
+ module Gini
4
+ module Api
5
+
6
+ # OAuth2 related methods to access API resources
7
+ #
8
+ class OAuth
9
+
10
+ attr_reader :token
11
+
12
+ # Instantiate a new Gini::Api::OAuth object and acquire token(s)
13
+ #
14
+ # @param [Gini::Api::Client] api Instance of Gini::Api::Client that contains all required params
15
+ # @param [Hash] opts Your authorization credentials
16
+ # @option opts [String] auth_code OAuth authorization code. Will be exchanged for a token
17
+ # @option opts [String] username API username
18
+ # @option opts [String] password API password
19
+ #
20
+ def initialize(api, opts)
21
+ # Initialize client. max_redirect is required as oauth2 will otherwise redirect to location from header (localhost)
22
+ # https://github.com/intridea/oauth2/blob/master/lib/oauth2/client.rb#L100
23
+ # Our code is encoded in the URL and has to be parsed from there.
24
+ client = OAuth2::Client.new(
25
+ api.client_id,
26
+ api.client_secret,
27
+ site: api.oauth_site,
28
+ authorize_url: '/authorize',
29
+ token_url: '/token',
30
+ max_redirects: 0,
31
+ raise_errors: true,
32
+ )
33
+
34
+ # Verify opts. Prefered authorization methis is auth_code. If no auth_code is present a login from username/password
35
+ # is done.
36
+ auth_code =
37
+ if opts.key?(:auth_code) && !opts[:auth_code].empty?
38
+ opts[:auth_code]
39
+ else
40
+ login_with_credentials(api, client, opts[:username], opts[:password])
41
+ end
42
+
43
+ # Exchange code for a real token.
44
+ # @token is a Oauth2::AccessToken object. Accesstoken is @token.token
45
+ @token = exchange_code_for_token(api, client, auth_code)
46
+
47
+ # Override OAuth2::AccessToken#refresh! to update self instead of returnign a new object
48
+ # Inspired by https://github.com/intridea/oauth2/issues/116#issuecomment-8097675
49
+ #
50
+ # @param [Hash] opts Refresh opts passed to original refresh! method
51
+ #
52
+ # @return [OAuth2::AccessToken] Updated access token object
53
+ #
54
+ def @token.refresh!(opts = {})
55
+ new_token = super
56
+ (new_token.instance_variables - %w[@refresh_token]).each do |name|
57
+ instance_variable_set(name, new_token.instance_variable_get(name))
58
+ end
59
+ self
60
+ end
61
+
62
+ # Override OAuth2::AccessToken#request to refresh token when less then 60 seconds left
63
+ #
64
+ # @param [Symbol] verb the HTTP request method
65
+ # @param [String] path the HTTP URL path of the request
66
+ # @param [Hash] opts the options to make the request with
67
+ #
68
+ def @token.request(verb, path, opts = {}, &block)
69
+ refresh! if refresh_token && (expires_at < Time.now.to_i + 60)
70
+ super
71
+ end
72
+ end
73
+
74
+ # Login with username/password
75
+ #
76
+ # @param [Gini::Api::Client] api API object
77
+ # @param [OAuth2::Client] client OAuth2 client object
78
+ # @param [String] username API username
79
+ # @param [String] password API password
80
+ #
81
+ # @return [String] Collected authorization code
82
+ #
83
+ def login_with_credentials(api, client, username, password)
84
+ # Generate CSRF token to verify the response
85
+ csrf_token = SecureRandom.hex
86
+
87
+ # Build authentication URI
88
+ auth_uri = client.auth_code.authorize_url(
89
+ redirect_uri: api.oauth_redirect,
90
+ state: csrf_token
91
+ )
92
+
93
+ begin
94
+ # Accquire auth code
95
+ response = client.request(
96
+ :post,
97
+ auth_uri,
98
+ body: { username: username, password: password }
99
+ )
100
+ unless response.status == 303
101
+ raise Gini::Api::OAuthError.new(
102
+ "API login failed (code=#{response.status})",
103
+ response
104
+ )
105
+ end
106
+ rescue OAuth2::Error => e
107
+ raise Gini::Api::OAuthError.new(
108
+ "Failed to acquire auth_code (code=#{e.response.status})",
109
+ e.response
110
+ )
111
+ end
112
+
113
+ # Parse the location header from the response and fill hash
114
+ # query_params ({'code' => '123abc', 'state' => 'supersecret'})
115
+ begin
116
+ q = URI.parse(response.headers['location']).query
117
+ query_params = Hash[*q.split(/\=|\&/)]
118
+ rescue => e
119
+ raise Gini::Api::OAuthError.new("Failed to parse location header: #{e.message}")
120
+ end
121
+
122
+ unless query_params['state'] == csrf_token
123
+ raise Gini::Api::OAuthError.new(
124
+ "CSRF token mismatch detected (should=#{csrf_token}, "\
125
+ "is=#{query_params['state']})"
126
+ )
127
+ end
128
+
129
+ unless query_params.key?('code') && !query_params['code'].empty?
130
+ raise Gini::Api::OAuthError.new(
131
+ "Failed to extract code from location #{response.headers['location']}"
132
+ )
133
+ end
134
+
135
+ query_params['code']
136
+ end
137
+
138
+ # Exchange auth_code for a real token
139
+ #
140
+ # @param [Gini::Api::Client] api API object
141
+ # @param [OAuth2::Client] client OAuth2 client object
142
+ # @param [String] auth_code authorization code
143
+ #
144
+ # @return [OAuth2::AccessToken] AccessToken object
145
+ #
146
+ def exchange_code_for_token(api, client, auth_code)
147
+ client.auth_code.get_token(auth_code, redirect_uri: api.oauth_redirect)
148
+ rescue OAuth2::Error => e
149
+ raise Gini::Api::OAuthError.new(
150
+ "Failed to exchange auth_code for token (code=#{e.response.status})",
151
+ e.response
152
+ )
153
+ end
154
+
155
+ # Destroy token
156
+ #
157
+ def destroy
158
+ @token.refresh_token && @token.refresh!
159
+ response = token.delete("/accessToken/#{@token.token}")
160
+ unless response.status == 204
161
+ raise Gini::Api::OAuthError.new(
162
+ "Failed to destroy token /accessToken/#{@token.token} "\
163
+ "(code=#{response.status})",
164
+ response
165
+ )
166
+ end
167
+ rescue OAuth2::Error => e
168
+ raise Gini::Api::OAuthError.new(
169
+ "Failed to destroy token (code=#{e.response.status})",
170
+ e.response
171
+ )
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,9 @@
1
+ module Gini
2
+ module Api
3
+ # Base version for semantic versioning
4
+ BASE_VERSION = '0.9'
5
+
6
+ # Version string
7
+ VERSION = "#{BASE_VERSION}.2"
8
+ end
9
+ end
data/lib/gini-api.rb ADDED
@@ -0,0 +1,15 @@
1
+ require 'gini-api/error'
2
+ require 'gini-api/oauth'
3
+ require 'gini-api/client'
4
+ require 'gini-api/version'
5
+ require 'gini-api/document'
6
+ require 'gini-api/document/layout'
7
+ require 'gini-api/document/extractions'
8
+ require 'gini-api/documentset'
9
+
10
+ # Gini module namespace
11
+ module Gini
12
+ # Gini::Api module namespace
13
+ module Api
14
+ end
15
+ end
@@ -0,0 +1,505 @@
1
+ require 'spec_helper'
2
+
3
+ describe Gini::Api::Client do
4
+
5
+ let(:user) { 'user@gini.net' }
6
+ let(:pass) { 'secret' }
7
+ let(:auth_code) { '1234567890' }
8
+ let(:header) { 'application/vnd.gini.v1+json' }
9
+ let(:oauth) do
10
+ double(
11
+ 'Gini::Api::OAuth',
12
+ :token => 'TOKEN',
13
+ :destroy => nil
14
+ )
15
+ end
16
+
17
+ subject(:api) do
18
+ Gini::Api::Client.new(
19
+ client_id: 'gini-rspec',
20
+ client_secret: 'secret',
21
+ log: (Logger.new '/dev/null'),
22
+ )
23
+ end
24
+
25
+ it { should respond_to(:register_parser) }
26
+ it { should respond_to(:login) }
27
+ it { should respond_to(:logout) }
28
+ it { should respond_to(:version_header) }
29
+ it { should respond_to(:request) }
30
+ it { should respond_to(:upload) }
31
+ it { should respond_to(:delete) }
32
+ it { should respond_to(:get) }
33
+ it { should respond_to(:list) }
34
+ it { should respond_to(:search) }
35
+
36
+ describe '#new' do
37
+
38
+ it 'fails with missing options' do
39
+ expect { Gini::Api::Client.new }.to \
40
+ raise_error(Gini::Api::Error, /Mandatory option key is missing/)
41
+ end
42
+
43
+ it do
44
+ expect(api.log.class).to eq(Logger)
45
+ end
46
+
47
+ end
48
+
49
+ describe '#register_parser' do
50
+
51
+ it do
52
+ expect(OAuth2::Response::PARSERS.keys).to \
53
+ include(:gini_json)
54
+ end
55
+
56
+ it do
57
+ expect(OAuth2::Response::PARSERS.keys).to \
58
+ include(:gini_xml)
59
+ end
60
+
61
+ end
62
+
63
+ describe '#login' do
64
+
65
+ context 'with auth_code' do
66
+
67
+ it 'sets @token' do
68
+ expect(Gini::Api::OAuth).to \
69
+ receive(:new).with(
70
+ api, auth_code: auth_code
71
+ ) { oauth }
72
+ expect(oauth).to receive(:token)
73
+
74
+ api.login(auth_code: auth_code)
75
+ expect(api.token).to eql('TOKEN')
76
+ end
77
+
78
+ end
79
+
80
+ context 'with username/password' do
81
+
82
+ it 'sets @token' do
83
+ expect(Gini::Api::OAuth).to \
84
+ receive(:new).with(
85
+ api, username:
86
+ user, password: pass
87
+ ) { oauth }
88
+ expect(oauth).to receive(:token)
89
+
90
+ api.login(username: user, password: pass)
91
+ expect(api.token).to eql('TOKEN')
92
+ end
93
+
94
+ end
95
+
96
+ end
97
+
98
+ describe '#logout' do
99
+
100
+ it 'destroys token' do
101
+ expect(Gini::Api::OAuth).to \
102
+ receive(:new).with(
103
+ api,
104
+ auth_code: auth_code
105
+ ) { oauth }
106
+ expect(oauth).to receive(:token)
107
+ api.login(auth_code: auth_code)
108
+
109
+ expect(oauth).to receive(:destroy)
110
+ api.logout
111
+ end
112
+
113
+ end
114
+
115
+ describe '#version_header' do
116
+
117
+ let(:api) do
118
+ Gini::Api::Client.new(
119
+ client_id: 1,
120
+ client_secret: 2,
121
+ log: (Logger.new '/dev/null')
122
+ )
123
+ end
124
+
125
+ context 'with json' do
126
+
127
+ it 'returns accept header with json type' do
128
+ expect(api.version_header(:json)).to \
129
+ eql({ accept: header })
130
+ end
131
+
132
+ end
133
+
134
+ context 'with xml' do
135
+
136
+ it 'returns accept header with xml type' do
137
+ expect(api.version_header(:xml)).to \
138
+ eql({ accept: 'application/vnd.gini.v1+xml' })
139
+ end
140
+
141
+ end
142
+
143
+ end
144
+
145
+ context 'being logged in' do
146
+
147
+ before do
148
+ expect(Gini::Api::OAuth).to \
149
+ receive(:new).with(
150
+ api,
151
+ auth_code: auth_code
152
+ ) { oauth }
153
+ api.login(auth_code: auth_code)
154
+ end
155
+
156
+ describe '#request' do
157
+
158
+ let(:response) do
159
+ double('Response',
160
+ status: 200,
161
+ headers: {
162
+ 'content-type' => header
163
+ },
164
+ body: body
165
+ )
166
+ end
167
+
168
+ it 'token receives call' do
169
+ expect(api.token).to receive(:get)
170
+ api.request(:get, '/dummy')
171
+ end
172
+
173
+ it 'raises RequestError from OAuth2::Error' do
174
+ expect(api.token).to \
175
+ receive(:get).and_raise(
176
+ OAuth2::Error.new(double.as_null_object)
177
+ )
178
+ expect { api.request(:get, '/invalid') }.to \
179
+ raise_error(Gini::Api::RequestError)
180
+ end
181
+
182
+ it 'raises ProcessingError on timeout' do
183
+ expect(api.token).to \
184
+ receive(:get).and_raise(Timeout::Error)
185
+ expect { api.request(:get, '/timeout') }.to \
186
+ raise_error(Gini::Api::ProcessingError)
187
+ end
188
+
189
+ context 'return JSON as default' do
190
+
191
+ let(:body) do
192
+ {
193
+ a: 1,
194
+ b: 2
195
+ }.to_json
196
+ end
197
+
198
+ it do
199
+ expect(api.token).to \
200
+ receive(:get).and_return(OAuth2::Response.new(response))
201
+ expect(api.request(:get, '/dummy').parsed).to be_a Hash
202
+ end
203
+
204
+ end
205
+
206
+ context 'return XML on request' do
207
+
208
+ let(:header) { 'application/vnd.gini.v1+xml' }
209
+ let(:body) { '<data><a>1</a><b>2</b></data>' }
210
+
211
+ it do
212
+ expect(api.token).to \
213
+ receive(:get).and_return(
214
+ OAuth2::Response.new(response)
215
+ )
216
+
217
+ expect(api.request(
218
+ :get,
219
+ '/dummy',
220
+ type: 'xml'
221
+ ).parsed).to be_a Hash
222
+ end
223
+
224
+ end
225
+
226
+ context 'set custom accept header' do
227
+
228
+ let(:header) { 'application/octet-stream' }
229
+ let(:body) { 'Just a string' }
230
+
231
+ it do
232
+ expect(api.token).to \
233
+ receive(:get).with(
234
+ %r{/dummy},
235
+ headers: {
236
+ accept: 'application/octet-stream'
237
+ }
238
+ ).and_return(OAuth2::Response.new(response))
239
+ expect(api.request(
240
+ :get,
241
+ '/dummy',
242
+ headers: {
243
+ accept: 'application/octet-stream'
244
+ }
245
+ ).body).to be_a String
246
+ end
247
+
248
+ end
249
+
250
+ end
251
+
252
+ describe '#upload' do
253
+
254
+ let(:doc) { double(Gini::Api::Document, poll: true, id: 'abc-123') }
255
+
256
+ before do
257
+ allow(doc).to receive(:duration=)
258
+ allow(Gini::Api::Document).to \
259
+ receive(:new).with(api, 'LOC'
260
+ ) { doc }
261
+ api.token.stub(:token).and_return('abc-123')
262
+ stub_request(
263
+ :post,
264
+ %r{/documents}
265
+ ).to_return(
266
+ status: status,
267
+ headers: {
268
+ location: 'LOC'
269
+ },
270
+ body: {}
271
+ )
272
+ end
273
+
274
+ context 'when failed' do
275
+
276
+ let(:status) { 500 }
277
+
278
+ it do
279
+ expect { api.upload('spec/integration/files/test.pdf') }.to \
280
+ raise_error(Gini::Api::UploadError)
281
+ end
282
+
283
+ end
284
+
285
+ context 'when successful' do
286
+
287
+ let(:status) { 201 }
288
+
289
+ it 'Gini::Api::Document is created' do
290
+ api.upload('spec/integration/files/test.pdf')
291
+ end
292
+ end
293
+
294
+ context 'on timeout' do
295
+
296
+ let(:status) { 201 }
297
+
298
+ it 'raises ProcessingError on timeout' do
299
+ expect(doc).to receive(:poll).and_raise(Timeout::Error)
300
+ expect { api.upload('spec/integration/files/test.pdf') }.to \
301
+ raise_error(Gini::Api::ProcessingError)
302
+ end
303
+
304
+ end
305
+
306
+ end
307
+
308
+ describe '#delete' do
309
+
310
+ let(:response) do
311
+ double('Response',
312
+ status: status,
313
+ env: {},
314
+ body: {}
315
+ )
316
+ end
317
+
318
+ context 'with invalid docId' do
319
+
320
+ let(:status) { 203 }
321
+
322
+ it do
323
+ api.token.stub(:delete).and_return(response)
324
+ expect { api.delete('abc-123') }.to \
325
+ raise_error(Gini::Api::DocumentError, /Deletion of docId abc-123 failed/)
326
+ end
327
+
328
+ end
329
+
330
+ context 'with valid docId' do
331
+
332
+ let(:status) { 204 }
333
+
334
+ it do
335
+ api.token.stub(:delete).and_return(response)
336
+ expect(api.delete('abc-123')).to be_true
337
+ end
338
+
339
+ end
340
+
341
+ end
342
+
343
+ describe '#get' do
344
+
345
+ it do
346
+ expect(Gini::Api::Document).to \
347
+ receive(:new) { double('Gini::Api::Document') }
348
+ api.get('abc-123')
349
+ end
350
+
351
+ end
352
+
353
+ describe '#list' do
354
+
355
+ let(:response) do
356
+ double('Response',
357
+ status: 200,
358
+ headers: {
359
+ 'content-type' => header
360
+ },
361
+ body: {
362
+ totalCount: doc_count,
363
+ next: nil,
364
+ documents: documents
365
+ }.to_json)
366
+ end
367
+
368
+ before do
369
+ api.token.stub(:get).and_return(OAuth2::Response.new(response))
370
+ end
371
+
372
+ context 'with documents' do
373
+
374
+ let(:doc_count) { 1 }
375
+ let(:documents) do
376
+ [
377
+ {
378
+ id: 42,
379
+ :_links => {
380
+ :document => 'https://rspec/123-abc'
381
+ }
382
+ }
383
+ ]
384
+ end
385
+
386
+ it do
387
+ expect(api.list.total).to eql(1)
388
+ expect(api.list.offset).to be_nil
389
+ expect(api.list.documents[0]).to be_a(Gini::Api::Document)
390
+ expect(api.list).to be_a(Gini::Api::DocumentSet)
391
+ end
392
+
393
+ end
394
+
395
+ context 'without documents' do
396
+
397
+ let(:doc_count) { 0 }
398
+ let(:documents) { [] }
399
+
400
+ it do
401
+ expect(api.list.total).to eql(0)
402
+ expect(api.list.offset).to be_nil
403
+ expect(api.list.documents).to eql([])
404
+ expect(api.list).to be_a(Gini::Api::DocumentSet)
405
+ end
406
+
407
+ end
408
+
409
+ context 'with failed http request' do
410
+
411
+ let(:response) do
412
+ double('Response',
413
+ status: 500,
414
+ env: {},
415
+ body: {}
416
+ )
417
+ end
418
+
419
+ it do
420
+ expect { api.list }.to \
421
+ raise_error(Gini::Api::DocumentError, /Failed to get list of documents/)
422
+ end
423
+
424
+ end
425
+
426
+ end
427
+
428
+ describe '#search' do
429
+
430
+ before do
431
+ api.token.stub(:get).and_return(OAuth2::Response.new(response))
432
+ end
433
+
434
+ let(:status) { 200 }
435
+ let(:response) do
436
+ double('Response',
437
+ status: status,
438
+ headers: {
439
+ 'content-type' => header
440
+ },
441
+ body: {
442
+ totalCount: doc_count,
443
+ next: nil,
444
+ documents: documents
445
+ }.to_json
446
+ )
447
+ end
448
+
449
+ context 'with found documents' do
450
+
451
+ let(:doc_count) { 1 }
452
+ let(:documents) do
453
+ [
454
+ {
455
+ id: '0f122e10-8dba-11e3-8a85-02015140775',
456
+ _links: {
457
+ document: 'https://rspec/123-abc'
458
+ }
459
+ }
460
+ ]
461
+ end
462
+
463
+ it do
464
+ result = api.search('invoice')
465
+
466
+ expect(result.total).to eql(1)
467
+ expect(result.offset).to be_nil
468
+ expect(result.documents[0]).to be_a(Gini::Api::Document)
469
+ expect(result).to be_a(Gini::Api::DocumentSet)
470
+ end
471
+
472
+ end
473
+
474
+ context 'with no found documents' do
475
+
476
+ let(:doc_count) { 0 }
477
+ let(:documents) { [] }
478
+
479
+ it do
480
+ result = api.search('invoice')
481
+
482
+ expect(result.total).to eql(0)
483
+ expect(result.offset).to be_nil
484
+ expect(result.documents).to eql([])
485
+ expect(result).to be_a(Gini::Api::DocumentSet)
486
+ end
487
+
488
+ end
489
+
490
+ context 'with failed query' do
491
+
492
+ let(:response) { double('Response', status: 500, env: {}, body: {}) }
493
+
494
+ it do
495
+ expect{api.search('invoice')}.to \
496
+ raise_error(Gini::Api::SearchError, /Search query failed with code 500/)
497
+ end
498
+
499
+ end
500
+
501
+ end
502
+
503
+ end
504
+
505
+ end