bootic_client 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,47 @@
1
+ require 'logger'
2
+ require "bootic_client/version"
3
+ require "bootic_client/entity"
4
+ require "bootic_client/relation"
5
+ require "bootic_client/client"
6
+
7
+ module BooticClient
8
+
9
+ AUTH_HOST = 'https://auth.bootic.net'.freeze
10
+ API_ROOT = 'https://api.bootic.net/v1'.freeze
11
+
12
+ class << self
13
+
14
+ attr_accessor :client_secret, :client_id, :logging, :cache_store
15
+ attr_writer :auth_host, :api_root, :logger
16
+
17
+ def strategies
18
+ @strategies ||= {}
19
+ end
20
+
21
+ def client(strategy_name, client_opts = {}, &on_new_token)
22
+ opts = client_opts.dup
23
+ opts[:logging] = logging
24
+ opts[:logger] = logger if logging
25
+ opts[:cache_store] = cache_store if cache_store
26
+ require "bootic_client/strategies/#{strategy_name}"
27
+ strategies.fetch(strategy_name.to_sym).new self, opts, &on_new_token
28
+ end
29
+
30
+ def auth_host
31
+ @auth_host || AUTH_HOST
32
+ end
33
+
34
+ def api_root
35
+ @api_root || API_ROOT
36
+ end
37
+
38
+ def logger
39
+ @logger || ::Logger.new(STDOUT)
40
+ end
41
+
42
+ def configure(&block)
43
+ yield self
44
+ end
45
+ end
46
+
47
+ end
@@ -0,0 +1,117 @@
1
+ require 'spec_helper'
2
+ require 'jwt'
3
+
4
+ describe 'BooticClient::Strategies::Authorized' do
5
+ require 'webmock/rspec'
6
+
7
+ let(:client_id) {'aaa'}
8
+ let(:client_secret) {'bbb'}
9
+
10
+ def jwt_assertion(expired_token, now)
11
+ JWT.encode({
12
+ iss: client_id,
13
+ aud: 'api',
14
+ prn: expired_token,
15
+ exp: now.utc.to_i + 5
16
+ }, client_secret, 'HS256')
17
+ end
18
+
19
+ def stub_api_root(access_token, status, body)
20
+ stub_request(:get, "https://api.bootic.net/v1").
21
+ with(headers: {'Accept'=>'*/*', 'Authorization' => "Bearer #{access_token}"}).
22
+ to_return(status: status, :body => JSON.dump(body))
23
+ end
24
+
25
+ def stub_auth(expired_token, status, body)
26
+ now = Time.now
27
+ Time.stub(:now).and_return now
28
+
29
+ stub_request(:post, "https://auth.bootic.net/oauth/token").
30
+ with(body: {
31
+ "assertion" => jwt_assertion(expired_token, now),
32
+ "assertion_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
33
+ "client_id" => "",
34
+ "client_secret" => "",
35
+ "grant_type" => "assertion",
36
+ "scope"=>""
37
+ },
38
+ headers: {
39
+ 'Content-Type'=>'application/x-www-form-urlencoded'
40
+ }).
41
+ to_return(status: status, body: JSON.dump(body), headers: {'Content-Type' => 'application/json'})
42
+ end
43
+
44
+ let(:store){ Hash.new }
45
+ let(:root_data) {
46
+ {
47
+ '_links' => {
48
+ 'shops' => {'href' => 'https://api.bootic.net/v1/products'}
49
+ },
50
+ 'message' => "Hello!"
51
+ }
52
+ }
53
+
54
+ describe 'with missing client credentials' do
55
+ it 'raises error' do
56
+ expect{
57
+ BooticClient.client(:authorized)
58
+ }.to raise_error
59
+ end
60
+ end
61
+
62
+ describe 'with no access_token' do
63
+ it 'raises error' do
64
+ expect{
65
+ BooticClient.client(:authorized)
66
+ }.to raise_error
67
+ end
68
+ end
69
+
70
+ describe 'with valid client credentials and access_token' do
71
+
72
+ let(:client) do
73
+ BooticClient.client(:authorized, access_token: 'abc') do |new_token|
74
+ store[:access_token] = new_token
75
+ end
76
+ end
77
+
78
+ before do
79
+ BooticClient.configure do |c|
80
+ c.client_id = client_id
81
+ c.client_secret = client_secret
82
+ end
83
+ end
84
+
85
+ context 'with valid token' do
86
+ before do
87
+ @root_request = stub_api_root('abc', 200, message: 'Hello!')
88
+ end
89
+
90
+ it 'does not request new token to auth service' do
91
+ root = client.root
92
+ expect(@root_request).to have_been_requested
93
+ expect(root.message).to eql('Hello!')
94
+ end
95
+ end
96
+
97
+ context 'with expired token' do
98
+ before do
99
+ @failed_root_request = stub_api_root('abc', 401, message: 'Unauthorized')
100
+ @auth_request = stub_auth('abc', 200, access_token: 'foobar')
101
+ @successful_root_request = stub_api_root('foobar', 200, root_data)
102
+ end
103
+
104
+ it 'attempts unauthorised API request, refreshes token from auth an tries again' do
105
+ root = client.root
106
+ expect(@failed_root_request).to have_been_requested
107
+ expect(@auth_request).to have_been_requested
108
+ expect(root.message).to eql('Hello!')
109
+ end
110
+
111
+ it 'yields new token to optional block' do
112
+ client.root
113
+ expect(store[:access_token]).to eql('foobar')
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,8 @@
1
+ require 'spec_helper'
2
+
3
+ describe BooticClient do
4
+ it 'should have a version number' do
5
+ expect(BooticClient::VERSION).not_to eql(nil)
6
+ end
7
+
8
+ end
@@ -0,0 +1,96 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'BooticClient::Strategies::ClientCredentials' do
4
+ require 'webmock/rspec'
5
+
6
+ let(:store){ Hash.new }
7
+ let(:root_data) {
8
+ {
9
+ '_links' => {
10
+ 'shops' => {'href' => 'https://api.bootic.net/v1/products'}
11
+ },
12
+ 'message' => "Hello!"
13
+ }
14
+ }
15
+
16
+ describe 'with missing client credentials' do
17
+ it 'raises error' do
18
+ expect{
19
+ BooticClient.client(:client_credentials, scope: 'admin')
20
+ }.to raise_error
21
+ end
22
+ end
23
+
24
+ describe 'with valid client credentials' do
25
+
26
+ def stub_auth(status, body)
27
+ stub_request(:post, "https://aaa:bbb@auth.bootic.net/oauth/token").
28
+ with(body: {"grant_type"=>"client_credentials", 'scope' => 'admin'}).
29
+ to_return(status: status, body: JSON.dump(body), headers: {'Content-Type' => 'application/json'})
30
+ end
31
+
32
+ def stub_api_root(access_token, status, body)
33
+ stub_request(:get, "https://api.bootic.net/v1").
34
+ with(headers: {'Accept'=>'*/*', 'Authorization' => "Bearer #{access_token}"}).
35
+ to_return(status: status, :body => JSON.dump(body))
36
+ end
37
+
38
+ before do
39
+ BooticClient.configure do |c|
40
+ c.client_id = 'aaa'
41
+ c.client_secret = 'bbb'
42
+ end
43
+ end
44
+
45
+ context 'with no previous access token' do
46
+ let(:client) do
47
+ BooticClient.client(:client_credentials, scope: 'admin') do |new_token|
48
+ store[:access_token] = new_token
49
+ end
50
+ end
51
+
52
+ before do
53
+ @auth_request = stub_auth(200, access_token: 'foobar')
54
+ stub_api_root 'foobar', 200, root_data
55
+ end
56
+
57
+ it 'requests access token via client_credentials flow' do
58
+ root = client.root
59
+ expect(@auth_request).to have_been_requested
60
+ expect(root.message).to eql('Hello!')
61
+ end
62
+
63
+ it 'yields new token to optional block' do
64
+ client.root
65
+ expect(store[:access_token]).to eql('foobar')
66
+ end
67
+ end
68
+
69
+ context 'with an expired access token' do
70
+ let(:client) do
71
+ BooticClient.client(:client_credentials, scope: 'admin', access_token: 'abc') do |new_token|
72
+ store[:access_token] = new_token
73
+ end
74
+ end
75
+
76
+ before do
77
+ @failed_root_request = stub_api_root('abc', 401, message: 'Unauthorized')
78
+ @auth_request = stub_auth(200, access_token: 'foobar')
79
+ @successful_root_request = stub_api_root('foobar', 200, root_data)
80
+ end
81
+
82
+ it 'attempts unauthorised API request, gets token from auth an tries again' do
83
+ root = client.root
84
+ expect(@failed_root_request).to have_been_requested
85
+ expect(@auth_request).to have_been_requested
86
+ expect(root.message).to eql('Hello!')
87
+ end
88
+
89
+ it 'yields new token to optional block' do
90
+ client.root
91
+ expect(store[:access_token]).to eql('foobar')
92
+ end
93
+ end
94
+ end
95
+
96
+ end
@@ -0,0 +1,139 @@
1
+ require 'spec_helper'
2
+ require 'json'
3
+
4
+ describe BooticClient::Client do
5
+ require 'webmock/rspec'
6
+
7
+ describe 'valid response' do
8
+ let(:root_url) { 'https://api.bootic.net/v1' }
9
+ let(:client) { BooticClient::Client.new(root_url, access_token: 'xxx') }
10
+ let(:response_headers) {
11
+ {'Content-Type' => 'application/json', 'Last-Modified' => 'Sat, 07 Jun 2014 12:10:33 GMT'}
12
+ }
13
+ let(:root_data) {
14
+ {
15
+ '_links' => {
16
+ 'shops' => {'href' => 'https://api.bootic.net/v1/products'}
17
+ },
18
+ 'message' => "Hello!"
19
+ }
20
+ }
21
+
22
+ describe '#get' do
23
+
24
+ context 'fresh' do
25
+ before do
26
+ stub_request(:get, root_url)
27
+ .to_return(status: 200, body: JSON.dump(root_data), headers: response_headers)
28
+ end
29
+
30
+ let!(:response) { client.get(root_url) }
31
+
32
+ it 'returns parsed Faraday response' do
33
+ expect(response).to be_kind_of(Faraday::Response)
34
+ expect(response.status).to eql(200)
35
+ response.body.tap do |b|
36
+ expect(b['_links']['shops']).to eql({'href' => 'https://api.bootic.net/v1/products'})
37
+ end
38
+ end
39
+
40
+ context 'and then cached' do
41
+ before do
42
+ @cached_request = stub_request(:get, root_url)
43
+ .with(headers: {'If-Modified-Since' => 'Sat, 07 Jun 2014 12:10:33 GMT'})
44
+ .to_return(status: 304, body: '', headers: response_headers)
45
+ end
46
+
47
+ it 'returns cached response' do
48
+ r = client.get(root_url)
49
+ expect(@cached_request).to have_been_requested
50
+
51
+ expect(r.status).to eql(200)
52
+ r.body.tap do |b|
53
+ expect(b['_links']['shops']).to eql({'href' => 'https://api.bootic.net/v1/products'})
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ context 'errors' do
60
+ describe 'no access token' do
61
+ it 'raises error' do
62
+ expect{
63
+ BooticClient::Client.new(root_url).get(root_url)
64
+ }.to raise_error(BooticClient::NoAccessTokenError)
65
+ end
66
+ end
67
+
68
+ describe '500 Server error' do
69
+ before do
70
+ stub_request(:get, root_url)
71
+ .to_return(status: 500, body: JSON.dump(message: 'Server error'), headers: response_headers)
72
+ end
73
+
74
+ it 'raises exception' do
75
+ expect{
76
+ client.get(root_url)
77
+ }.to raise_error(BooticClient::ServerError)
78
+ end
79
+ end
80
+
81
+ describe '404 Not Found' do
82
+ before do
83
+ stub_request(:get, root_url)
84
+ .to_return(status: 404, body: JSON.dump(message: 'not Found'), headers: response_headers)
85
+ end
86
+
87
+ it 'raises exception' do
88
+ expect{
89
+ client.get(root_url)
90
+ }.to raise_error(BooticClient::NotFoundError)
91
+ end
92
+ end
93
+
94
+ describe '401 Unauthorized' do
95
+ before do
96
+ stub_request(:get, root_url)
97
+ .to_return(status: 401, body: JSON.dump(message: 'Unauthorised'), headers: response_headers)
98
+ end
99
+
100
+ it 'raises exception' do
101
+ expect{
102
+ client.get(root_url)
103
+ }.to raise_error(BooticClient::UnauthorizedError)
104
+ end
105
+ end
106
+
107
+ describe '403 Access Forbidden' do
108
+ before do
109
+ stub_request(:get, root_url)
110
+ .to_return(status: 403, body: JSON.dump(message: 'Access Forbidden'), headers: response_headers)
111
+ end
112
+
113
+ it 'raises exception' do
114
+ expect{
115
+ client.get(root_url)
116
+ }.to raise_error(BooticClient::AccessForbiddenError)
117
+ end
118
+ end
119
+ end
120
+
121
+ end
122
+
123
+ describe '#get_and_wrap' do
124
+ before do
125
+ stub_request(:get, root_url)
126
+ .with(query: {foo: 'bar'})
127
+ .to_return(status: 200, body: JSON.dump(root_data), headers: response_headers)
128
+ end
129
+
130
+ it 'wraps JSON response in entity' do
131
+ wrapper = double('Wrapper Class')
132
+ entity = double('Entity')
133
+ expect(wrapper).to receive(:new).with(root_data, client).and_return entity
134
+ expect(client.get_and_wrap(root_url, wrapper, foo: 'bar')).to eql(entity)
135
+ end
136
+ end
137
+
138
+ end
139
+ end
@@ -0,0 +1,171 @@
1
+ require 'spec_helper'
2
+
3
+ describe BooticClient::Entity do
4
+ let(:client) { double(:client) }
5
+ let(:list_payload) do
6
+ {
7
+ 'total_items' => 10,
8
+ 'per_page' => 2,
9
+ 'page' => 1,
10
+ 'an_object' => {
11
+ 'name' => 'Foobar',
12
+ 'age' => 22
13
+ },
14
+ '_links' => {
15
+ 'self' => {'href' => '/foo'},
16
+ 'next' => { 'href' => '/foo?page=2'},
17
+ 'btc:products' => {'href' => '/all/products'},
18
+ 'btc:search' => {'href' => '/search{?q}', 'templated' => true},
19
+ 'curies' => [
20
+ {
21
+ 'name' => "btc",
22
+ 'href' => "https://developers.bootic.net/rels/{rel}",
23
+ 'templated' => true
24
+ }
25
+ ]
26
+ },
27
+ "_embedded" => {
28
+ 'items' => [
29
+ {
30
+ 'title' => 'iPhone 4',
31
+ 'price' => 12345,
32
+ '_links' => {
33
+ 'self' => {href: '/products/iphone4'},
34
+ 'btc:delete_product' => {'href' => '/products/12345'}
35
+ },
36
+ '_embedded' => {
37
+ 'shop' => {
38
+ 'name' => 'Acme'
39
+ }
40
+ }
41
+ },
42
+
43
+ {
44
+ 'title' => 'iPhone 5',
45
+ 'price' => 12342,
46
+ '_links' => {
47
+ 'self' => {href: '/products/iphone5'}
48
+ },
49
+ '_embedded' => {
50
+ 'shop' => {
51
+ 'name' => 'Apple'
52
+ }
53
+ }
54
+ }
55
+
56
+ ] # / items
57
+ }
58
+ }
59
+ end
60
+
61
+ context 'parsing JSON HAL' do
62
+ let(:entity) { BooticClient::Entity.new(list_payload, client) }
63
+
64
+ it 'knows about plain properties' do
65
+ expect(entity.total_items).to eql(10)
66
+ expect(entity.per_page).to eql(2)
67
+ expect(entity.page).to eql(1)
68
+ end
69
+
70
+ it 'wraps object properties as entities' do
71
+ expect(entity.an_object.name).to eql('Foobar')
72
+ expect(entity.an_object.age).to eql(22)
73
+ end
74
+
75
+ it 'has a #properties object' do
76
+ expect(entity.properties[:total_items]).to eql(10)
77
+ end
78
+
79
+ it 'responds to #has?' do
80
+ expect(entity.has?(:total_items)).to eql(true)
81
+ expect(entity.has?(:items)).to eql(true)
82
+ expect(entity.has?(:foobar)).to eql(false)
83
+ end
84
+
85
+ describe 'embedded entities' do
86
+
87
+ it 'has a #entities object' do
88
+ expect(entity.entities[:items]).to be_a(Array)
89
+ expect(entity.entities[:items].first.entities[:shop]).to be_kind_of(BooticClient::Entity)
90
+ end
91
+
92
+ it 'are exposed like normal attributes' do
93
+ expect(entity.items).to be_kind_of(Array)
94
+ entity.items.first.tap do |product|
95
+ expect(product).to be_kind_of(BooticClient::Entity)
96
+ expect(product.title).to eql('iPhone 4')
97
+ end
98
+ end
99
+
100
+ it 'recursively builds embedded entities' do
101
+ product = entity.items.first
102
+ product.shop.tap do |shop|
103
+ expect(shop).to be_kind_of(BooticClient::Entity)
104
+ expect(shop.name).to eql('Acme')
105
+ end
106
+ end
107
+ end #/ embedded entities
108
+
109
+ describe 'link relations' do
110
+ it 'responds to #has? for link relations' do
111
+ expect(entity.has?(:next)).to eql(true)
112
+ end
113
+
114
+ it 'builds relation objects' do
115
+ expect(entity.rels[:next]).to be_kind_of(BooticClient::Relation)
116
+ expect(entity.rels[:next].href).to eql('/foo?page=2')
117
+ end
118
+
119
+ it 'understands namespaced cURIes' do
120
+ expect(entity.rels[:products]).to be_kind_of(BooticClient::Relation)
121
+ expect(entity.rels[:products].href).to eql('/all/products')
122
+ end
123
+
124
+ it 'adds docs if cURIes available' do
125
+ expect(entity.rels[:products].docs).to eql('https://developers.bootic.net/rels/products')
126
+ end
127
+
128
+ it 'adds docs if cURIes available even in nested entities' do
129
+ prod = entity.items.first
130
+ expect(prod.rels[:delete_product].docs).to eql('https://developers.bootic.net/rels/delete_product')
131
+ end
132
+
133
+ context 'eagerly fetching rels' do
134
+ let(:next_page) { BooticClient::Entity.new({'page' => 2}, client) }
135
+
136
+ it 'exposes link target resources as normal properties' do
137
+ expect(client).to receive(:get_and_wrap).with('/foo?page=2', BooticClient::Entity, {}).and_return next_page
138
+ entity.next.tap do |next_entity|
139
+ expect(next_entity).to be_kind_of(BooticClient::Entity)
140
+ expect(next_entity.page).to eql(2)
141
+ end
142
+ end
143
+
144
+ it 'takes optional URI parameters' do
145
+ expect(client).to receive(:get_and_wrap).with('/search?q=foo', BooticClient::Entity).and_return next_page
146
+ entity.search(q: 'foo').tap do |next_entity|
147
+ expect(next_entity).to be_kind_of(BooticClient::Entity)
148
+ expect(next_entity.page).to eql(2)
149
+ end
150
+ end
151
+ end
152
+
153
+ end
154
+
155
+ describe 'iterating' do
156
+ it 'iterates items if it is a list' do
157
+ prods = []
158
+ entity.each{|pr| prods << pr}
159
+ expect(prods).to match_array(entity.items)
160
+ end
161
+
162
+ it 'iterates itself if not a list' do
163
+ ent = BooticClient::Entity.new({'foo' => 'bar'}, client)
164
+ ents = []
165
+ ent.each{|e| ents << e}
166
+ expect(ents).to match_array([ent])
167
+ end
168
+ end
169
+ end
170
+
171
+ end
@@ -0,0 +1,55 @@
1
+ require 'spec_helper'
2
+ require 'bootic_client/stores/memcache'
3
+
4
+ describe BooticClient::Stores::Memcache do
5
+ let(:dalli) { double('Dalli') }
6
+ let(:store) { BooticClient::Stores::Memcache.new(['localhost:1112'], foo: 'bar') }
7
+
8
+ before do
9
+ Dalli::Client.stub(:new).with(['localhost:1112'], foo: 'bar').and_return dalli
10
+ end
11
+
12
+ shared_examples_for 'dalli :get' do |method_name|
13
+ it 'delegates to Dalli client #get' do
14
+ expect(dalli).to receive(:get).with('foo').and_return 'bar'
15
+ expect(store.send(method_name, 'foo')).to eql('bar')
16
+ end
17
+ end
18
+
19
+ shared_examples_for 'dalli :set' do |method_name|
20
+ it 'delegates to Dalli client #set' do
21
+ expect(dalli).to receive(:set).with('foo', 'bar', 123).and_return 'bar'
22
+ expect(store.send(method_name, 'foo', 'bar', 123)).to eql('bar')
23
+ end
24
+ end
25
+
26
+ describe '#initialize' do
27
+ it 'creates a Dalli instance' do
28
+ expect(Dalli::Client).to receive(:new).with(['localhost:1112'], foo: 'bar').and_return dalli
29
+ expect(store.client).to eql(dalli)
30
+ end
31
+ end
32
+
33
+ describe '#read' do
34
+ it_behaves_like 'dalli :get', :read
35
+ end
36
+
37
+ describe '#get' do
38
+ it_behaves_like 'dalli :get', :get
39
+ end
40
+
41
+ describe '#write' do
42
+ it_behaves_like 'dalli :set', :write
43
+ end
44
+
45
+ describe '#set' do
46
+ it_behaves_like 'dalli :set', :set
47
+ end
48
+
49
+ describe '#stats' do
50
+ it 'delegates to Dalli client #stats' do
51
+ expect(dalli).to receive(:stats).and_return 'foobar'
52
+ expect(store.stats).to eql('foobar')
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,55 @@
1
+ require 'spec_helper'
2
+
3
+ describe BooticClient::Relation do
4
+ let(:client) { double(:client) }
5
+ let(:relation) { BooticClient::Relation.new({'href' => '/foo/bars', 'type' => 'application/json', 'title' => 'A relation', 'name' => 'self'}, client) }
6
+
7
+ describe 'attributes' do
8
+ it 'has readers for known relation attributes' do
9
+ expect(relation.href).to eql('/foo/bars')
10
+ expect(relation.type).to eql('application/json')
11
+ expect(relation.title).to eql('A relation')
12
+ expect(relation.name).to eql('self')
13
+ end
14
+ end
15
+
16
+ describe '#get' do
17
+ let(:entity) { BooticClient::Entity.new({'title' => 'Foobar'}, client) }
18
+
19
+ it 'fetches data and returns entity' do
20
+ client.stub(:get_and_wrap).with('/foo/bars', BooticClient::Entity, {}).and_return entity
21
+ expect(relation.get).to eql(entity)
22
+ end
23
+
24
+ context 'without URI templates' do
25
+ let(:relation) { BooticClient::Relation.new({'href' => '/foos/bar', 'type' => 'application/json', 'title' => 'A relation'}, client) }
26
+
27
+ it 'is not templated' do
28
+ expect(relation.templated?).to eql(false)
29
+ end
30
+
31
+ it 'passes query string to client' do
32
+ expect(client).to receive(:get_and_wrap).with('/foos/bar', BooticClient::Entity, id: 2, q: 'test', page: 2).and_return entity
33
+ expect(relation.get(id: 2, q: 'test', page: 2)).to eql(entity)
34
+ end
35
+ end
36
+
37
+ context 'with URI templates' do
38
+ let(:relation) { BooticClient::Relation.new({'href' => '/foos/{id}{?q,page}', 'type' => 'application/json', 'title' => 'A relation', 'templated' => true}, client) }
39
+
40
+ it 'is templated' do
41
+ expect(relation.templated?).to eql(true)
42
+ end
43
+
44
+ it 'works with defaults' do
45
+ expect(client).to receive(:get_and_wrap).with('/foos/', BooticClient::Entity).and_return entity
46
+ expect(relation.get).to eql(entity)
47
+ end
48
+
49
+ it 'interpolates tokens' do
50
+ expect(client).to receive(:get_and_wrap).with('/foos/2?q=test&page=2', BooticClient::Entity).and_return entity
51
+ expect(relation.get(id: 2, q: 'test', page: 2)).to eql(entity)
52
+ end
53
+ end
54
+ end
55
+ end