speakeasy_ruby_sdk 0.0.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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +201 -0
  3. data/README.md +184 -0
  4. data/lib/speakeasy_ruby_sdk/config.rb +60 -0
  5. data/lib/speakeasy_ruby_sdk/har_builder.rb +242 -0
  6. data/lib/speakeasy_ruby_sdk/http_transaction.rb +122 -0
  7. data/lib/speakeasy_ruby_sdk/masker.rb +166 -0
  8. data/lib/speakeasy_ruby_sdk/time.rb +42 -0
  9. data/lib/speakeasy_ruby_sdk/url_utils.rb +24 -0
  10. data/lib/speakeasy_ruby_sdk/version.rb +3 -0
  11. data/lib/speakeasy_ruby_sdk.rb +112 -0
  12. data/test/bulk_test.rb +176 -0
  13. data/test/http_test.rb +32 -0
  14. data/test/masker_test.rb +124 -0
  15. data/test/testdata/captures_basic_request_and_no_response_body_input.json +15 -0
  16. data/test/testdata/captures_basic_request_and_no_response_body_output.json +61 -0
  17. data/test/testdata/captures_basic_request_and_response_input.json +21 -0
  18. data/test/testdata/captures_basic_request_and_response_output.json +70 -0
  19. data/test/testdata/captures_basic_request_and_response_with_different_content_types_input.json +22 -0
  20. data/test/testdata/captures_basic_request_and_response_with_different_content_types_output.json +74 -0
  21. data/test/testdata/captures_basic_request_and_response_with_no_response_header_set_input.json +21 -0
  22. data/test/testdata/captures_basic_request_and_response_with_no_response_header_set_output.json +70 -0
  23. data/test/testdata/captures_basic_request_with_nano_precision_input.json +23 -0
  24. data/test/testdata/captures_basic_request_with_nano_precision_output.json +70 -0
  25. data/test/testdata/captures_cookies_input.json +35 -0
  26. data/test/testdata/captures_cookies_output.json +165 -0
  27. data/test/testdata/captures_masked_request_response_input.json +73 -0
  28. data/test/testdata/captures_masked_request_response_output.json +156 -0
  29. data/test/testdata/captures_no_response_body_when_not_modified_input.json +17 -0
  30. data/test/testdata/captures_no_response_body_when_not_modified_output.json +60 -0
  31. data/test/testdata/captures_post_request_with_body_input.json +24 -0
  32. data/test/testdata/captures_post_request_with_body_output.json +81 -0
  33. data/test/testdata/captures_query_params_input.json +21 -0
  34. data/test/testdata/captures_query_params_output.json +75 -0
  35. data/test/testdata/captures_redirect_input.json +22 -0
  36. data/test/testdata/captures_redirect_output.json +74 -0
  37. data/test/testdata/drops_request_and_response_bodies_when_request_body_too_large_input.json +24 -0
  38. data/test/testdata/drops_request_and_response_bodies_when_request_body_too_large_output.json +81 -0
  39. data/test/testdata/drops_response_body_when_too_large_input.json +24 -0
  40. data/test/testdata/drops_response_body_when_too_large_output.json +81 -0
  41. metadata +240 -0
@@ -0,0 +1,242 @@
1
+ module SpeakeasyRubySdk
2
+ class HarBuilder
3
+
4
+ def initialize api_config
5
+ @api_config = api_config
6
+ end
7
+
8
+ def construct_creator name, version
9
+ return {
10
+ "name": name,
11
+ "version": version
12
+ }
13
+ end
14
+
15
+ def construct_log version, creator, entries, comment
16
+ return {
17
+ 'log': {
18
+ "version": version,
19
+ "creator": creator,
20
+ "entries": entries,
21
+ "comment": comment
22
+ }
23
+ }
24
+ end
25
+
26
+
27
+ def construct_empty_cache
28
+ return {
29
+ }
30
+ end
31
+ def construct_empty_params
32
+ return []
33
+ end
34
+
35
+ def construct_query_records query_params
36
+ query_params.map {|k, v| {
37
+ "name": k,
38
+ "value": v
39
+ } }.sort_by(&lambda{ |h| h[:name] })
40
+ end
41
+
42
+ def construct_response_cookies cookies
43
+ final_cookies = []
44
+ if ! cookies.nil?
45
+ for cookie in cookies
46
+ new_cookie = {
47
+ 'name': cookie.name,
48
+ 'value': cookie.value,
49
+ }
50
+ if cookie.path && cookie.path != '/'
51
+ new_cookie['path'] = cookie.path
52
+ end
53
+ if cookie.domain
54
+ new_cookie['domain'] = cookie.domain
55
+ end
56
+ if cookie.expires
57
+ new_cookie['expires'] = cookie.expires.strftime("%Y-%m-%dT%H:%M:%S.%NZ")
58
+ end
59
+ if cookie.httponly
60
+ new_cookie['httpOnly'] = cookie.httponly
61
+ end
62
+ if cookie.secure
63
+ new_cookie['secure'] = cookie.secure
64
+ end
65
+ final_cookies << new_cookie
66
+ end
67
+ end
68
+ return final_cookies.sort_by(&lambda{ |c| c[:name] })
69
+ end
70
+
71
+ def construct_request_cookies cookies
72
+ if cookies.nil?
73
+ return []
74
+ else
75
+ return cookies.map{ |cookie|
76
+ {
77
+ 'name': cookie[0],
78
+ 'value': cookie[1]
79
+ }
80
+ }.sort_by(&lambda{ |c| c[:name] })
81
+ end
82
+ end
83
+
84
+ def construct_header_records headers
85
+ final_headers = []
86
+ for k, v in headers
87
+ if v.is_a? Array
88
+ for value in v
89
+ final_headers << {
90
+ "name": k,
91
+ "value": value
92
+ }
93
+ end
94
+ else
95
+ final_headers << {
96
+ "name": k,
97
+ "value": v
98
+ }
99
+ end
100
+ end
101
+ final_headers.sort_by(&lambda{ |h| h[:name] })
102
+ end
103
+
104
+ def construct_post_data request
105
+ content_type = request.headers['content-type']
106
+ if content_type.nil? || content_type.length == 0
107
+ content_type = "application/octet-stream"
108
+ end
109
+ if request.body.empty?
110
+ return nil
111
+ elsif (request.headers.include?('content-length')) && (!request.headers['content-length'].nil?) && (request.headers['content-length'].to_i > @api_config.max_capture_size)
112
+ return {
113
+ "mimeType": content_type,
114
+ "text": "--dropped--"
115
+ }
116
+ else
117
+ return {
118
+ "mimeType": content_type,
119
+ "text": request.body
120
+ }
121
+ end
122
+ end
123
+
124
+ def construct_response_content status, body, headers
125
+ content_type = headers['content-type']
126
+ if content_type.nil? || content_type.length == 0
127
+ content_type = "application/octet-stream"
128
+ end
129
+ if status == 304
130
+ return {
131
+ "mimeType": content_type,
132
+ "size": -1
133
+ }
134
+ elsif (headers.include?('content-length')) && (!headers['content-length'].nil?) && (headers['content-length'].to_i > @api_config.max_capture_size)
135
+ return {
136
+ "mimeType": content_type,
137
+ "text": "--dropped--",
138
+ "size": -1
139
+ }
140
+ elsif ! headers.include?('content-length')
141
+ return {
142
+ "mimeType": content_type,
143
+ "size": -1
144
+ }
145
+ else
146
+ return {
147
+ "mimeType": content_type,
148
+ "text": body,
149
+ "size": headers['content-length'].to_i
150
+ }
151
+ end
152
+ end
153
+
154
+ def calculate_header_size headers
155
+ raw_headers = ''
156
+ for k, v in headers
157
+ if v.is_a? Array
158
+ for value in v
159
+ raw_headers += "#{k}: #{value}\r\n"
160
+ end
161
+ else
162
+ raw_headers += "#{k}: #{v}\r\n"
163
+ end
164
+ end
165
+ raw_headers.bytesize
166
+ end
167
+
168
+ def construct_request request
169
+ req = {
170
+ "method": request.method,
171
+ "url": request.url,
172
+ "httpVersion": request.transaction.protocol,
173
+ "cookies": self.construct_request_cookies(request.cookies),
174
+ "headers": self.construct_header_records(request.headers),
175
+ "queryString": self.construct_query_records(request.query_params),
176
+ "headersSize": self.calculate_header_size(request.headers),
177
+ "bodySize": request.content_length.to_i,
178
+ }
179
+ if ! self.construct_post_data(request).nil?
180
+ req["postData"] = self.construct_post_data(request)
181
+ end
182
+ req
183
+ end
184
+
185
+ def construct_response response
186
+ res = {
187
+ "status": response.status,
188
+ "statusText": Rack::Utils::HTTP_STATUS_CODES[response.status],
189
+ "httpVersion": response.transaction.protocol,
190
+ "cookies": self.construct_response_cookies(response.cookies),
191
+ "headers": self.construct_header_records(response.headers),
192
+ "content": self.construct_response_content(response.status, response.body, response.headers),
193
+ "redirectURL": response.headers.fetch('location', ''),
194
+ "headersSize": self.calculate_header_size(response.headers)
195
+ }
196
+
197
+ if response.status == 304
198
+ res["bodySize"] = 0
199
+ elsif !response.headers.include?('content-length')
200
+ res["bodySize"] = -1
201
+ else
202
+ res["bodySize"] = response.body.bytesize
203
+ end
204
+ res
205
+ end
206
+
207
+ def construct_timings
208
+ return {
209
+ "send": -1,
210
+ "wait": -1,
211
+ "receive": -1,
212
+ }
213
+ end
214
+
215
+ def construct_entries http_transaction
216
+ entry = {
217
+ "startedDateTime": http_transaction.time_utils.start_time.strftime("%Y-%m-%dT%H:%M:%S.%NZ"),
218
+ "time": http_transaction.time_utils.elapsed_time,
219
+ "request": self.construct_request(http_transaction.request),
220
+ "response": self.construct_response(http_transaction.response),
221
+ "serverIPAddress": http_transaction.request.url.hostname,
222
+ "cache": self.construct_empty_cache,
223
+ "timings": self.construct_timings
224
+ }
225
+ if !http_transaction.port.nil? && http_transaction.port != "80"
226
+ entry["connection"] = http_transaction.port
227
+ end
228
+ return [entry]
229
+ end
230
+
231
+ def construct_har http_transaction
232
+ creator = construct_creator SpeakeasyRubySdk.to_s, SpeakeasyRubySdk::VERSION
233
+ version = "1.2"
234
+ comment = "request capture for #{http_transaction.request.url.to_s}"
235
+
236
+ entries = construct_entries http_transaction
237
+
238
+ log = construct_log version, creator, entries, comment
239
+ log.to_json
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,122 @@
1
+ require 'http-cookie'
2
+
3
+ module SpeakeasyRubySdk
4
+ class HttpTransaction
5
+
6
+ attr_reader :time_utils, :status, :env, :request, :response, :protocol, :port
7
+
8
+ def handle_forward_headers request_headers
9
+ if request_headers.include? 'x-forwarded-proto' && request_headers['x-forwarded-proto']
10
+ scheme = request_headers['x-forwarded-proto'].downcase
11
+ elsif request_headers.include? 'x-forwarded-scheme' && request_headers['x-forwarded-scheme']
12
+ scheme = request_headers['x-forwarded-scheme'].downcase
13
+ elsif request_headers.include? 'forwarded' && request_headers['forwarded']
14
+ forwarded = request_headers['forwarded']
15
+ protoRegex = Regexp.new(/(?i)(?:proto=)(https|http)/)
16
+ matches = forwarded.match(protoRegexp)
17
+ if matches.length > 1
18
+ scheme = matches[1].downcase
19
+ end
20
+ end
21
+ scheme
22
+ end
23
+
24
+ def initialize time_utils, env, status, response_headers, response_body, masker
25
+ ## Setup Data
26
+ @time_utils = time_utils
27
+ @status = status
28
+ @env = env
29
+ @protocol = env['SERVER_PROTOCOL']
30
+ @port = env['SERVER_PORT']
31
+
32
+ if ! response_body.nil? && response_body.respond_to?(:body)
33
+ response_body = response_body.body
34
+ elsif !response_body.nil? && response_body.respond_to?(:join)
35
+ response_body = response_body.join
36
+ elsif response_body.nil?
37
+ response_body = ''
38
+ end
39
+ # normalize request headers
40
+ request_headers = Hash[*env.select {|k,v| k.start_with? 'HTTP_'}
41
+ .collect {|k,v| [k.sub(/^HTTP_/, ''), v]}
42
+ .collect {|k,v| [k.split('_').collect(&:downcase).join('-'), v]}
43
+ .sort
44
+ .flatten]
45
+ request_body = env['rack.input'].read
46
+ request_method = env['REQUEST_METHOD']
47
+ rack_request = Rack::Request.new(env)
48
+ response_headers = Hash[response_headers.collect {|k,v| [k.downcase, v] }]
49
+ scheme = self.handle_forward_headers request_headers
50
+ if scheme
51
+ @protocol = scheme
52
+ end
53
+ query_params = Rack::Utils.parse_nested_query(env['QUERY_STRING'])
54
+
55
+ ## Temporary url var for populating cookiejar.
56
+ unmasked_url = UrlUtils.resolve_url env
57
+
58
+ request_cookies = CGI::Cookie.parse(request_headers['cookie'] || '').map { |cookie| [cookie[0], cookie[1][0]] }
59
+ response_cookies = HTTP::CookieJar.new()
60
+ if response_headers.include? 'set-cookie'
61
+ cookies = response_headers['set-cookie']
62
+ cookies.map { |cookie| response_cookies.parse(cookie, unmasked_url) }
63
+ end
64
+
65
+ ## Begin Masking
66
+ masked_query_params = masker.mask_query_params unmasked_url.path, query_params
67
+
68
+ masked_request_headers = masker.mask_request_headers unmasked_url.path, request_headers
69
+ masked_response_headers = masker.mask_response_headers unmasked_url.path, response_headers
70
+
71
+ request_url = UrlUtils.resolve_url env, masked_query_params
72
+
73
+ masked_request_cookies = masker.mask_request_cookies unmasked_url.path, request_cookies
74
+ masked_response_cookies = masker.mask_response_cookies unmasked_url.path, response_cookies
75
+
76
+ masked_request_body = masker.mask_request_body unmasked_url.path, request_body
77
+ masked_response_body = masker.mask_response_body unmasked_url.path, response_body
78
+
79
+
80
+ ## Construct Request Response
81
+ @request = HttpRequest.new self, rack_request, request_url, request_method, masked_request_headers, masked_query_params, masked_request_body, masked_request_cookies
82
+ @response = HttpResponse.new self, status, masked_response_headers, masked_response_body, masked_response_cookies
83
+ end
84
+ end
85
+
86
+ class HttpRequest
87
+ attr_reader :transaction, :method, :url, :query_params, :headers, :body, :cookies
88
+ def initialize transaction, rack_request, request_url, request_method, request_headers, query_params, request_body, request_cookies
89
+ @transaction = transaction
90
+ @rack_request = rack_request
91
+ @url = request_url
92
+ @query_params = query_params
93
+ @method = request_method
94
+ @headers = request_headers
95
+ @body = request_body
96
+ @cookies = request_cookies
97
+ end
98
+
99
+ def content_type
100
+ @rack_request.content_type
101
+ end
102
+
103
+ def content_length
104
+ if @rack_request.content_length == "0"
105
+ -1
106
+ else
107
+ @rack_request.content_length
108
+ end
109
+ end
110
+ end
111
+
112
+ class HttpResponse
113
+ attr_reader :transaction, :status, :headers, :body, :cookies
114
+ def initialize transaction, status, response_headers, response_body, response_cookies
115
+ @transaction = transaction
116
+ @status = (status.nil? || status == -1) ? 200 : status
117
+ @headers = response_headers
118
+ @body = response_body
119
+ @cookies = response_cookies
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,166 @@
1
+ module SpeakeasyRubySdk
2
+
3
+ class MaskConfig
4
+ attr_reader :type, :attributes, :masks, :controller
5
+ def initialize type, attributes, masks=nil, controller=nil
6
+ @type = type
7
+ @contoller = controller
8
+ @attributes = attributes
9
+ @masks = masks
10
+ end
11
+
12
+ def get_mask_for_attribute attribute
13
+ if @masks.nil? || @masks.empty?
14
+ SpeakeasyRubySdk::Masker::SIMPLE_MASK
15
+ elsif @masks.length == 1
16
+ @masks[0]
17
+ else
18
+ i = @attributes.find_index{|att| att == attribute}
19
+ if i > @masks.length
20
+ SpeakeasyRubySdk::Masker::SIMPLE_MASK
21
+ else
22
+ @masks[i]
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ class Masker
29
+
30
+ SIMPLE_MASK = '__masked__'
31
+ SIMPLE_NUMBER_MASK = -12321
32
+
33
+ def initialize config
34
+ @masks = {
35
+ :query_params => [],
36
+ :request_headers => [],
37
+ :response_headers => [],
38
+ :request_cookies => [],
39
+ :response_cookies => [],
40
+ :request_body_string => [],
41
+ :request_body_number => [],
42
+ :response_body_string => [],
43
+ :response_body_number => []
44
+ }
45
+ # todo remove this dependency for other mask control
46
+ @routes = config.routes
47
+
48
+ if config.masking
49
+ config.masking.map {|mask| @masks[mask.type] << mask}
50
+ end
51
+ end
52
+
53
+ def mask_value mask, attribute, path
54
+ if !mask.controller.nil?
55
+ route = @routes.recognize_path path
56
+ if mask.controller === route[:prefix]
57
+ return mask.get_mask_for_attribute attribute
58
+ else
59
+ return value;
60
+ end
61
+ else
62
+ return mask.get_mask_for_attribute attribute
63
+ end
64
+ end
65
+
66
+ def mask_pair masking_key, path, attribute, value
67
+ masked_value = value
68
+ if @masks.include? masking_key
69
+ masks = @masks[masking_key]
70
+ for mask in masks
71
+ if mask.attributes.include? attribute.to_s.downcase
72
+ masked_value = mask_value mask, attribute, path
73
+ end
74
+ end
75
+ end
76
+ return masked_value
77
+ end
78
+
79
+ def mask_dict masking_key, path, hash_map
80
+ masked_dict = {}
81
+ for key, value in hash_map
82
+ masked_dict[key] = mask_pair masking_key, path, key, value
83
+ if value.is_a? Array
84
+ masked_dict[key] = value.map { |v| mask_pair masking_key, path, key, v }
85
+ end
86
+ end
87
+ masked_dict
88
+ end
89
+
90
+ def mask_query_params path, query_params
91
+ mask_dict :query_params, path, query_params
92
+ end
93
+
94
+ def mask_request_headers path, headers
95
+ mask_dict :request_headers, path, headers
96
+ end
97
+
98
+ def mask_response_headers path, headers
99
+ mask_dict :response_headers, path, headers
100
+ end
101
+
102
+ def mask_request_cookies path, cookies
103
+ mask_dict :request_cookies, path, cookies
104
+ end
105
+
106
+ def mask_response_cookies path, cookies
107
+ for cookie in cookies
108
+ key = cookie.name
109
+ value = cookie.value
110
+ masked_value = self.mask_pair :response_cookies, path, key, value
111
+ cookie.value = masked_value
112
+ end
113
+ cookies
114
+ end
115
+
116
+ def mask_body_string masking_key_prefix, path, body
117
+ masking_key = "#{masking_key_prefix}_string".to_sym
118
+
119
+ masked_body = body
120
+ if @masks.include? masking_key
121
+ for mask in @masks[masking_key]
122
+ for attribute in mask.attributes
123
+
124
+ regex_string = Regexp.new "(\"#{attribute}\": *)(\".*?[^\\\\]\")( *[, \\n\\r}]?)"
125
+
126
+ matches = body.match(regex_string)
127
+ if matches
128
+ masked_body = masked_body.gsub(regex_string, "#{matches[1]}\"#{mask.get_mask_for_attribute(attribute)}\"#{matches[3]}")
129
+ end
130
+ end
131
+ end
132
+ end
133
+ masked_body
134
+ end
135
+ def mask_body_number masking_key_prefix, path, body
136
+ masking_key = "#{masking_key_prefix}_number".to_sym
137
+
138
+ masked_body = body
139
+ if @masks.include? masking_key
140
+ for mask in @masks[masking_key]
141
+ for attribute in mask.attributes
142
+ regex_string = Regexp.new "(\"#{attribute}\": *)(-?[0-9]+\\.?[0-9]*)( *[, \\n\\r}]?)"
143
+ matches = body.match(regex_string)
144
+ if matches
145
+ masked_body = masked_body.gsub(regex_string, "#{matches[1]}#{mask.get_mask_for_attribute(attribute)}#{matches[3]}")
146
+ end
147
+ end
148
+ end
149
+ end
150
+ masked_body
151
+ end
152
+
153
+ def mask_request_body path, body
154
+ masked_body = mask_body_string 'request_body', path, body
155
+ mask_body_number 'request_body', path, masked_body
156
+ end
157
+
158
+ def mask_response_body path, body
159
+ masked_body = mask_body_string 'response_body', path, body
160
+ mask_body_number 'response_body', path, masked_body
161
+ end
162
+
163
+ end
164
+ end
165
+
166
+
@@ -0,0 +1,42 @@
1
+ module SpeakeasyRubySdk
2
+ class TimeUtils
3
+ ## Convenience class to optionally override times
4
+ ## In test environment
5
+ def initialize(start_time=nil, elapsed_time=nil)
6
+ @start_time = start_time
7
+ @elapsed_time = elapsed_time
8
+ @end_time = nil
9
+ end
10
+
11
+ def start_time
12
+ now
13
+ end
14
+
15
+ def now
16
+ if @start_time.nil?
17
+ @start_time
18
+ @start_time = Time.now
19
+ return @start_time
20
+ else
21
+ return @start_time
22
+ end
23
+ end
24
+
25
+ def set_end_time
26
+ @end_time = Time.now
27
+ end
28
+
29
+ def elapsed_time
30
+ if !@elapsed_time.nil?
31
+ return @elapsed_time
32
+ else
33
+ @elapsed_time = time_difference(@end_time, @start_time)
34
+ end
35
+ end
36
+
37
+ def time_difference end_time, start_time
38
+ ((end_time - start_time)).to_i
39
+ end
40
+
41
+ end
42
+ end
@@ -0,0 +1,24 @@
1
+ require 'uri'
2
+ module SpeakeasyRubySdk
3
+ module UrlUtils
4
+ def self.resolve_url env, query_params=nil
5
+ if env.include?('SERVER_PORT') && env['SERVER_PORT'] != "80"
6
+ request_uri = "#{env['SERVER_NAME']}:#{env['SERVER_PORT']}#{env['PATH_INFO']}"
7
+ else
8
+ request_uri = "#{env['SERVER_NAME']}#{env['PATH_INFO']}"
9
+ end
10
+
11
+ if request_uri.include? '?'
12
+ request_uri = request_uri.split('?')[0]
13
+ end
14
+ scheme = env['rack.url_scheme']
15
+ host = env['HTTP_HOST']
16
+ if !query_params.nil? && !query_params.empty?
17
+ updated_query_string = URI.encode_www_form query_params
18
+ URI("#{scheme}://#{host}#{request_uri}?#{updated_query_string}")
19
+ else
20
+ URI("#{scheme}://#{host}#{request_uri}")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,3 @@
1
+ module SpeakeasyRubySdk
2
+ VERSION = '0.0.2'
3
+ end