bootic_client 0.0.1

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,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