gini-api 0.9.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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