lhc 13.0.0 → 15.0.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.
- checksums.yaml +4 -4
- data/.github/workflows/rubocop.yml +15 -0
- data/.github/workflows/test.yml +15 -0
- data/.rubocop.yml +344 -19
- data/.ruby-version +1 -1
- data/README.md +89 -0
- data/Rakefile +3 -3
- data/lhc.gemspec +3 -1
- data/lib/lhc.rb +70 -59
- data/lib/lhc/concerns/lhc/fix_invalid_encoding_concern.rb +1 -0
- data/lib/lhc/config.rb +16 -0
- data/lib/lhc/endpoint.rb +3 -0
- data/lib/lhc/error.rb +7 -4
- data/lib/lhc/interceptor.rb +4 -0
- data/lib/lhc/interceptors.rb +1 -0
- data/lib/lhc/interceptors/auth.rb +10 -5
- data/lib/lhc/interceptors/caching.rb +14 -3
- data/lib/lhc/interceptors/logging.rb +4 -2
- data/lib/lhc/interceptors/monitoring.rb +46 -11
- data/lib/lhc/interceptors/retry.rb +2 -0
- data/lib/lhc/interceptors/rollbar.rb +3 -2
- data/lib/lhc/interceptors/throttle.rb +7 -2
- data/lib/lhc/interceptors/zipkin.rb +2 -0
- data/lib/lhc/request.rb +37 -4
- data/lib/lhc/response.rb +1 -0
- data/lib/lhc/response/data.rb +1 -1
- data/lib/lhc/scrubber.rb +45 -0
- data/lib/lhc/scrubbers/auth_scrubber.rb +33 -0
- data/lib/lhc/scrubbers/body_scrubber.rb +28 -0
- data/lib/lhc/scrubbers/headers_scrubber.rb +38 -0
- data/lib/lhc/scrubbers/params_scrubber.rb +14 -0
- data/lib/lhc/version.rb +1 -1
- data/spec/config/scrubs_spec.rb +108 -0
- data/spec/error/to_s_spec.rb +13 -8
- data/spec/formats/multipart_spec.rb +2 -2
- data/spec/formats/plain_spec.rb +1 -1
- data/spec/interceptors/after_response_spec.rb +1 -1
- data/spec/interceptors/caching/main_spec.rb +2 -2
- data/spec/interceptors/caching/multilevel_cache_spec.rb +2 -1
- data/spec/interceptors/define_spec.rb +1 -0
- data/spec/interceptors/logging/main_spec.rb +21 -1
- data/spec/interceptors/monitoring/caching_spec.rb +66 -0
- data/spec/interceptors/response_competition_spec.rb +2 -2
- data/spec/interceptors/return_response_spec.rb +2 -2
- data/spec/interceptors/rollbar/main_spec.rb +27 -15
- data/spec/request/scrubbed_headers_spec.rb +101 -0
- data/spec/request/scrubbed_options_spec.rb +194 -0
- data/spec/request/scrubbed_params_spec.rb +35 -0
- data/spec/response/data_spec.rb +2 -2
- data/spec/support/zipkin_mock.rb +1 -0
- metadata +40 -21
- data/.rubocop.localch.yml +0 -325
- data/cider-ci.yml +0 -5
- data/cider-ci/bin/bundle +0 -51
- data/cider-ci/bin/ruby_install +0 -8
- data/cider-ci/bin/ruby_version +0 -25
- data/cider-ci/jobs/rspec-activesupport-5.yml +0 -27
- data/cider-ci/jobs/rspec-activesupport-6.yml +0 -28
- data/cider-ci/jobs/rubocop.yml +0 -18
- data/cider-ci/task_components/bundle.yml +0 -22
- data/cider-ci/task_components/rspec.yml +0 -36
- data/cider-ci/task_components/rubocop.yml +0 -29
- data/cider-ci/task_components/ruby.yml +0 -15
data/lib/lhc/response.rb
CHANGED
data/lib/lhc/response/data.rb
CHANGED
data/lib/lhc/scrubber.rb
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class LHC::Scrubber
|
4
|
+
attr_accessor :scrubbed
|
5
|
+
|
6
|
+
SCRUB_DISPLAY = '[FILTERED]'
|
7
|
+
|
8
|
+
def initialize(data)
|
9
|
+
@scrubbed = data
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def scrub_auth_elements
|
15
|
+
LHC.config.scrubs.dig(:auth)
|
16
|
+
end
|
17
|
+
|
18
|
+
def scrub!
|
19
|
+
return if scrub_elements.blank?
|
20
|
+
return if scrubbed.blank?
|
21
|
+
|
22
|
+
LHC::Scrubber.scrub_hash!(scrub_elements, scrubbed) if scrubbed.is_a?(Hash)
|
23
|
+
LHC::Scrubber.scrub_array!(scrub_elements, scrubbed) if scrubbed.is_a?(Array)
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.scrub_array!(scrub_elements, scrubbed)
|
27
|
+
scrubbed.each do |scrubbed_hash|
|
28
|
+
LHC::Scrubber.scrub_hash!(scrub_elements, scrubbed_hash)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.scrub_hash!(scrub_elements, scrubbed)
|
33
|
+
scrub_elements.each do |scrub_element|
|
34
|
+
if scrubbed.key?(scrub_element.to_s)
|
35
|
+
key = scrub_element.to_s
|
36
|
+
elsif scrubbed.key?(scrub_element.to_sym)
|
37
|
+
key = scrub_element.to_sym
|
38
|
+
end
|
39
|
+
next if key.blank? || scrubbed[key].blank?
|
40
|
+
|
41
|
+
scrubbed[key] = SCRUB_DISPLAY
|
42
|
+
end
|
43
|
+
scrubbed.values.each { |v| LHC::Scrubber.scrub_hash!(scrub_elements, v) if v.instance_of?(Hash) }
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class LHC::AuthScrubber < LHC::Scrubber
|
4
|
+
def initialize(data)
|
5
|
+
super(data)
|
6
|
+
scrub_auth_options!
|
7
|
+
end
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
def scrub_auth_options!
|
12
|
+
return if scrubbed.blank?
|
13
|
+
return if scrub_auth_elements.blank?
|
14
|
+
|
15
|
+
scrub_basic_auth_options! if scrub_auth_elements.include?(:basic)
|
16
|
+
scrub_bearer_auth_options! if scrub_auth_elements.include?(:bearer)
|
17
|
+
end
|
18
|
+
|
19
|
+
def scrub_basic_auth_options!
|
20
|
+
return if scrubbed[:basic].blank?
|
21
|
+
|
22
|
+
scrubbed[:basic][:username] = SCRUB_DISPLAY
|
23
|
+
scrubbed[:basic][:password] = SCRUB_DISPLAY
|
24
|
+
scrubbed[:basic][:base_64_encoded_credentials] = SCRUB_DISPLAY
|
25
|
+
end
|
26
|
+
|
27
|
+
def scrub_bearer_auth_options!
|
28
|
+
return if scrubbed[:bearer].blank?
|
29
|
+
|
30
|
+
scrubbed[:bearer] = SCRUB_DISPLAY if scrubbed[:bearer].is_a?(String)
|
31
|
+
scrubbed[:bearer_token] = SCRUB_DISPLAY
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class LHC::BodyScrubber < LHC::Scrubber
|
4
|
+
def initialize(data)
|
5
|
+
super(data)
|
6
|
+
parse!
|
7
|
+
scrub!
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def scrub_elements
|
13
|
+
LHC.config.scrubs[:body]
|
14
|
+
end
|
15
|
+
|
16
|
+
def parse!
|
17
|
+
return if scrubbed.nil? || scrubbed.is_a?(Hash) || scrubbed.is_a?(Array)
|
18
|
+
|
19
|
+
if scrubbed.is_a?(String)
|
20
|
+
json = scrubbed
|
21
|
+
else
|
22
|
+
json = scrubbed.to_json
|
23
|
+
end
|
24
|
+
|
25
|
+
parsed = JSON.parse(json)
|
26
|
+
self.scrubbed = parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class LHC::HeadersScrubber < LHC::Scrubber
|
4
|
+
def initialize(data, auth_options)
|
5
|
+
super(data)
|
6
|
+
@auth_options = auth_options
|
7
|
+
scrub!
|
8
|
+
scrub_auth_headers!
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
attr_reader :auth_options
|
14
|
+
|
15
|
+
def scrub_elements
|
16
|
+
LHC.config.scrubs[:headers]
|
17
|
+
end
|
18
|
+
|
19
|
+
def scrub_auth_headers!
|
20
|
+
return if scrub_auth_elements.blank?
|
21
|
+
return if auth_options.blank?
|
22
|
+
|
23
|
+
scrub_basic_authentication_headers! if scrub_auth_elements.include?(:basic)
|
24
|
+
scrub_bearer_authentication_headers! if scrub_auth_elements.include?(:bearer)
|
25
|
+
end
|
26
|
+
|
27
|
+
def scrub_basic_authentication_headers!
|
28
|
+
return if auth_options[:basic].blank? || scrubbed['Authorization'].blank?
|
29
|
+
|
30
|
+
scrubbed['Authorization'].gsub!(auth_options[:basic][:base_64_encoded_credentials], SCRUB_DISPLAY)
|
31
|
+
end
|
32
|
+
|
33
|
+
def scrub_bearer_authentication_headers!
|
34
|
+
return if auth_options[:bearer].blank? || scrubbed['Authorization'].blank?
|
35
|
+
|
36
|
+
scrubbed['Authorization'].gsub!(auth_options[:bearer_token], SCRUB_DISPLAY)
|
37
|
+
end
|
38
|
+
end
|
data/lib/lhc/version.rb
CHANGED
@@ -0,0 +1,108 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails_helper'
|
4
|
+
|
5
|
+
describe LHC do
|
6
|
+
it 'has a default value for scrubs' do
|
7
|
+
expect(LHC.config.scrubs[:auth]).to eq [:bearer, :basic]
|
8
|
+
expect(LHC.config.scrubs[:params]).to eq []
|
9
|
+
expect(LHC.config.scrubs[:headers]).to eq []
|
10
|
+
expect(LHC.config.scrubs[:body]).to eq ['password', 'password_confirmation']
|
11
|
+
end
|
12
|
+
|
13
|
+
describe 'auth' do
|
14
|
+
context 'when only bearer auth should get scrubbed' do
|
15
|
+
before(:each) do
|
16
|
+
LHC.configure do |c|
|
17
|
+
c.scrubs[:auth] = [:bearer]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'has only bearer auth in scrubs' do
|
22
|
+
expect(LHC.config.scrubs[:auth]).to eq([:bearer])
|
23
|
+
expect(LHC.config.scrubs[:params]).to eq []
|
24
|
+
expect(LHC.config.scrubs[:headers]).to eq []
|
25
|
+
expect(LHC.config.scrubs[:body]).to eq ['password', 'password_confirmation']
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
context 'params' do
|
31
|
+
context 'when additional param "api_key" should be scrubbed' do
|
32
|
+
before(:each) do
|
33
|
+
LHC.configure do |c|
|
34
|
+
c.scrubs[:params] << 'api_key'
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
it 'has "api_key" in scrubs' do
|
39
|
+
expect(LHC.config.scrubs[:auth]).to eq [:bearer, :basic]
|
40
|
+
expect(LHC.config.scrubs[:params]).to eq ['api_key']
|
41
|
+
expect(LHC.config.scrubs[:headers]).to eq []
|
42
|
+
expect(LHC.config.scrubs[:body]).to eq ['password', 'password_confirmation']
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
context 'headers' do
|
48
|
+
context 'when additional header "private_key" should be scrubbed' do
|
49
|
+
before(:each) do
|
50
|
+
LHC.configure do |c|
|
51
|
+
c.scrubs[:headers] << 'private_key'
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'has "private_key" in scrubs' do
|
56
|
+
expect(LHC.config.scrubs[:auth]).to eq [:bearer, :basic]
|
57
|
+
expect(LHC.config.scrubs[:params]).to eq []
|
58
|
+
expect(LHC.config.scrubs[:headers]).to eq ['private_key']
|
59
|
+
expect(LHC.config.scrubs[:body]).to eq ['password', 'password_confirmation']
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
context 'body' do
|
65
|
+
context 'when only password should get scrubbed' do
|
66
|
+
before(:each) do
|
67
|
+
LHC.configure do |c|
|
68
|
+
c.scrubs[:body] = ['password']
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
it 'has password in scrubs' do
|
73
|
+
expect(LHC.config.scrubs[:auth]).to eq [:bearer, :basic]
|
74
|
+
expect(LHC.config.scrubs[:params]).to eq []
|
75
|
+
expect(LHC.config.scrubs[:headers]).to eq []
|
76
|
+
expect(LHC.config.scrubs[:body]).to eq(['password'])
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
context 'when "user_token" should be scrubbed' do
|
81
|
+
before(:each) do
|
82
|
+
LHC.configure do |c|
|
83
|
+
c.scrubs[:body] << 'user_token'
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
it 'has user_token in scrubs' do
|
88
|
+
expect(LHC.config.scrubs[:auth]).to eq [:bearer, :basic]
|
89
|
+
expect(LHC.config.scrubs[:params]).to eq []
|
90
|
+
expect(LHC.config.scrubs[:headers]).to eq []
|
91
|
+
expect(LHC.config.scrubs[:body]).to eq(['password', 'password_confirmation', 'user_token'])
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
context 'when nothing should be scrubbed' do
|
97
|
+
before(:each) do
|
98
|
+
LHC.configure do |c|
|
99
|
+
c.scrubs = {}
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
it 'does not have scrubs' do
|
104
|
+
expect(LHC.config.scrubs.blank?).to be true
|
105
|
+
expect(LHC.config.scrubs[:auth]).to be nil
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
data/spec/error/to_s_spec.rb
CHANGED
@@ -45,17 +45,17 @@ describe LHC::Error do
|
|
45
45
|
|
46
46
|
context 'some mocked response' do
|
47
47
|
let(:request) do
|
48
|
-
double('
|
48
|
+
double('LHC::Request',
|
49
49
|
method: 'GET',
|
50
50
|
url: 'http://example.com/sessions',
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
51
|
+
scrubbed_headers: { 'Bearer Token' => LHC::Scrubber::SCRUB_DISPLAY },
|
52
|
+
scrubbed_options: { followlocation: true,
|
53
|
+
auth: { bearer: LHC::Scrubber::SCRUB_DISPLAY },
|
54
|
+
params: { limit: 20 }, url: "http://example.com/sessions" })
|
55
55
|
end
|
56
56
|
|
57
57
|
let(:response) do
|
58
|
-
double('
|
58
|
+
double('LHC::Response',
|
59
59
|
request: request,
|
60
60
|
code: 500,
|
61
61
|
options: { return_code: :internal_error, response_headers: "" },
|
@@ -64,11 +64,16 @@ describe LHC::Error do
|
|
64
64
|
|
65
65
|
subject { LHC::Error.new('The error message', response) }
|
66
66
|
|
67
|
+
before do
|
68
|
+
allow(request).to receive(:is_a?).with(LHC::Request).and_return(true)
|
69
|
+
allow(response).to receive(:is_a?).with(LHC::Response).and_return(true)
|
70
|
+
end
|
71
|
+
|
67
72
|
it 'produces correct debug output' do
|
68
73
|
expect(subject.to_s.split("\n")).to eq(<<-MSG.strip_heredoc.split("\n"))
|
69
74
|
GET http://example.com/sessions
|
70
|
-
Options: {:followlocation=>true, :auth=>{:bearer=>"
|
71
|
-
Headers: {"Bearer Token"=>"
|
75
|
+
Options: {:followlocation=>true, :auth=>{:bearer=>"#{LHC::Scrubber::SCRUB_DISPLAY}"}, :params=>{:limit=>20}, :url=>"http://example.com/sessions"}
|
76
|
+
Headers: {"Bearer Token"=>"#{LHC::Scrubber::SCRUB_DISPLAY}"}
|
72
77
|
Response Code: 500 (internal_error)
|
73
78
|
Response Options: {:return_code=>:internal_error, :response_headers=>""}
|
74
79
|
{"status":500,"message":"undefined"}
|
@@ -6,14 +6,14 @@ describe LHC do
|
|
6
6
|
include ActionDispatch::TestProcess
|
7
7
|
|
8
8
|
context 'multipart' do
|
9
|
-
let(:file) {
|
9
|
+
let(:file) { Rack::Test::UploadedFile.new(Tempfile.new) }
|
10
10
|
let(:body) { { size: 2231 }.to_json }
|
11
11
|
let(:location) { 'http://local.ch/uploads/image.jpg' }
|
12
12
|
|
13
13
|
it 'formats requests to be multipart/form-data' do
|
14
14
|
stub_request(:post, 'http://local.ch/') do |request|
|
15
15
|
raise 'Content-Type header wrong' unless request.headers['Content-Type'] == 'multipart/form-data'
|
16
|
-
raise 'Body wrongly formatted' unless request.body.match(/file=%23%3CActionDispatch%3A%3AHttp%3A%3AUploadedFile%3A.*%3E&type=Image/)
|
16
|
+
raise 'Body wrongly formatted' unless request.body.match?(/file=%23%3CActionDispatch%3A%3AHttp%3A%3AUploadedFile%3A.*%3E&type=Image/)
|
17
17
|
end.to_return(status: 200, body: body, headers: { 'Location' => location })
|
18
18
|
response = LHC.multipart.post(
|
19
19
|
'http://local.ch',
|
data/spec/formats/plain_spec.rb
CHANGED
@@ -6,7 +6,7 @@ describe LHC do
|
|
6
6
|
include ActionDispatch::TestProcess
|
7
7
|
|
8
8
|
context 'plain' do
|
9
|
-
let(:file) {
|
9
|
+
let(:file) { Rack::Test::UploadedFile.new(Tempfile.new) }
|
10
10
|
|
11
11
|
it 'leaves plains requests unformatted' do
|
12
12
|
stub_request(:post, 'http://local.ch/')
|
@@ -14,7 +14,7 @@ describe LHC do
|
|
14
14
|
uri = URI.parse(response.request.url)
|
15
15
|
path = [
|
16
16
|
'web',
|
17
|
-
Rails.application.class.parent_name,
|
17
|
+
((ActiveSupport.gem_version >= Gem::Version.new('6.0.0')) ? Rails.application.class.module_parent_name : Rails.application.class.parent_name).underscore,
|
18
18
|
Rails.env,
|
19
19
|
response.request.method,
|
20
20
|
uri.scheme,
|
@@ -47,7 +47,7 @@ describe LHC::Caching do
|
|
47
47
|
|
48
48
|
it 'lets you configure the cache key that will be used' do
|
49
49
|
LHC.config.endpoint(:local, 'http://local.ch', cache: { key: 'STATICKEY' })
|
50
|
-
expect(Rails.cache).to receive(:fetch).with("LHC_CACHE(v#{LHC::Caching::CACHE_VERSION}): STATICKEY").and_call_original
|
50
|
+
expect(Rails.cache).to receive(:fetch).at_least(:once).with("LHC_CACHE(v#{LHC::Caching::CACHE_VERSION}): STATICKEY").and_call_original
|
51
51
|
expect(Rails.cache).to receive(:write).with("LHC_CACHE(v#{LHC::Caching::CACHE_VERSION}): STATICKEY", anything, anything).and_call_original
|
52
52
|
stub
|
53
53
|
LHC.get(:local)
|
@@ -66,8 +66,8 @@ describe LHC::Caching do
|
|
66
66
|
stub
|
67
67
|
LHC.config.endpoint(:local, 'http://local.ch', cache: true)
|
68
68
|
original_response = LHC.get(:local)
|
69
|
-
cached_response = LHC.get(:local)
|
70
69
|
expect(original_response.from_cache?).to eq false
|
70
|
+
cached_response = LHC.get(:local)
|
71
71
|
expect(cached_response.from_cache?).to eq true
|
72
72
|
end
|
73
73
|
end
|
@@ -62,7 +62,8 @@ describe LHC::Caching do
|
|
62
62
|
|
63
63
|
context 'found in central cache' do
|
64
64
|
it 'serves it from central cache if found there' do
|
65
|
-
expect(redis_cache).to receive(:fetch).and_return(nil,
|
65
|
+
expect(redis_cache).to receive(:fetch).and_return(nil,
|
66
|
+
body: '<h1>Hi there</h1>', code: 200, headers: nil, return_code: nil, mock: :webmock)
|
66
67
|
expect(redis_cache).to receive(:write).and_return(true)
|
67
68
|
expect(Rails.cache).to receive(:fetch).and_call_original
|
68
69
|
expect(Rails.cache).to receive(:write).and_call_original
|
@@ -8,7 +8,7 @@ describe LHC::Logging do
|
|
8
8
|
before(:each) do
|
9
9
|
LHC.config.interceptors = [LHC::Logging]
|
10
10
|
LHC::Logging.logger = logger
|
11
|
-
stub_request(:get,
|
11
|
+
stub_request(:get, /http:\/\/local.ch.*/).to_return(status: 200)
|
12
12
|
end
|
13
13
|
|
14
14
|
it 'does log information before and after every request made with LHC' do
|
@@ -34,4 +34,24 @@ describe LHC::Logging do
|
|
34
34
|
)
|
35
35
|
end
|
36
36
|
end
|
37
|
+
|
38
|
+
context 'sensitive data' do
|
39
|
+
before :each do
|
40
|
+
LHC.config.scrubs[:params] << 'api_key'
|
41
|
+
LHC.config.scrubs[:headers] << 'private_key'
|
42
|
+
LHC.get('http://local.ch', params: { api_key: '123-abc' }, headers: { private_key: 'abc-123' })
|
43
|
+
end
|
44
|
+
|
45
|
+
it 'does not log sensitive params information' do
|
46
|
+
expect(logger).to have_received(:info).once.with(
|
47
|
+
a_string_including("Params={:api_key=>\"#{LHC::Scrubber::SCRUB_DISPLAY}\"}")
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'does not log sensitive header information' do
|
52
|
+
expect(logger).to have_received(:info).once.with(
|
53
|
+
a_string_including(":private_key=>\"#{LHC::Scrubber::SCRUB_DISPLAY}\"")
|
54
|
+
)
|
55
|
+
end
|
56
|
+
end
|
37
57
|
end
|