http2 0.0.28 → 0.0.33
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/README.md +150 -28
- data/Rakefile +20 -31
- data/lib/http2.rb +105 -70
- data/lib/http2/base_request.rb +22 -0
- data/{include → lib/http2}/connection.rb +56 -28
- data/lib/http2/cookie.rb +22 -0
- data/lib/http2/errors.rb +18 -0
- data/lib/http2/get_request.rb +33 -0
- data/{include → lib/http2}/post_data_generator.rb +7 -6
- data/{include → lib/http2}/post_multipart_request.rb +20 -20
- data/{include → lib/http2}/post_request.rb +17 -22
- data/lib/http2/response.rb +109 -0
- data/{include → lib/http2}/response_reader.rb +75 -42
- data/{include → lib/http2}/url_builder.rb +6 -6
- data/lib/http2/utils.rb +52 -0
- data/spec/helpers.rb +27 -0
- data/spec/http2/cookies_spec.rb +18 -0
- data/spec/http2/get_request_spec.rb +11 -0
- data/spec/http2/post_data_generator_spec.rb +2 -1
- data/spec/http2/post_multipart_request_spec.rb +11 -0
- data/spec/http2/post_request_spec.rb +11 -0
- data/spec/http2/response_reader_spec.rb +12 -0
- data/spec/http2/response_spec.rb +73 -0
- data/spec/http2/url_builder_spec.rb +1 -1
- data/spec/http2_spec.rb +107 -89
- data/spec/spec_helper.rb +8 -8
- data/spec/spec_root/content_type_test.rhtml +4 -0
- data/spec/spec_root/cookie_test.rhtml +8 -0
- data/spec/spec_root/json_test.rhtml +9 -0
- data/spec/spec_root/multipart_test.rhtml +28 -0
- data/spec/spec_root/redirect_test.rhtml +3 -0
- data/spec/spec_root/unauthorized.rhtml +3 -0
- data/spec/spec_root/unsupported_media_type.rhtml +3 -0
- metadata +118 -36
- data/.document +0 -5
- data/.rspec +0 -1
- data/Gemfile +0 -14
- data/Gemfile.lock +0 -77
- data/VERSION +0 -1
- data/http2.gemspec +0 -77
- data/include/errors.rb +0 -11
- data/include/get_request.rb +0 -34
- data/include/response.rb +0 -73
- data/include/utils.rb +0 -40
- data/shippable.yml +0 -7
@@ -0,0 +1,109 @@
|
|
1
|
+
# This object will be returned as the response for each request.
|
2
|
+
class Http2::Response
|
3
|
+
# All the data the response contains. Headers, body, cookies, requested URL and more.
|
4
|
+
attr_reader :headers, :request, :request_args, :requested_url
|
5
|
+
attr_accessor :body, :charset, :code, :http_version
|
6
|
+
attr_writer :content_type
|
7
|
+
|
8
|
+
# This method should not be called manually.
|
9
|
+
def initialize(body: "", debug: false, headers: {}, request:)
|
10
|
+
@body = body
|
11
|
+
@debug = debug
|
12
|
+
@headers = headers
|
13
|
+
@request = request
|
14
|
+
@requested_url = request.path
|
15
|
+
end
|
16
|
+
|
17
|
+
# Returns a certain header by name or false if not found.
|
18
|
+
#===Examples
|
19
|
+
# val = res.header("content-type")
|
20
|
+
def header(key)
|
21
|
+
return false unless headers.key?(key)
|
22
|
+
|
23
|
+
headers.fetch(key).first.to_s
|
24
|
+
end
|
25
|
+
|
26
|
+
# Returns true if a header of the given string exists.
|
27
|
+
#===Examples
|
28
|
+
# print "No content-type was given." if !http.header?("content-type")
|
29
|
+
def header?(key)
|
30
|
+
return true if headers.key?(key) && !headers[key].first.to_s.empty?
|
31
|
+
|
32
|
+
false
|
33
|
+
end
|
34
|
+
|
35
|
+
def content_length
|
36
|
+
if header?("content-length")
|
37
|
+
header("content-length").to_i
|
38
|
+
elsif @body
|
39
|
+
@body.bytesize
|
40
|
+
else
|
41
|
+
raise "Couldn't calculate content-length."
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def content_type
|
46
|
+
if header?("content-type")
|
47
|
+
header("content-type")
|
48
|
+
else
|
49
|
+
raise "No content-type was given."
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Checks the data that has been sat on the object and raises various exceptions, if it does not validate somehow.
|
54
|
+
def validate!
|
55
|
+
puts "Http2: Validating response length." if @debug
|
56
|
+
validate_body_versus_content_length!
|
57
|
+
end
|
58
|
+
|
59
|
+
# Returns true if the result is JSON.
|
60
|
+
def json?
|
61
|
+
content_type == "application/json"
|
62
|
+
end
|
63
|
+
|
64
|
+
def json
|
65
|
+
@json ||= JSON.parse(body)
|
66
|
+
end
|
67
|
+
|
68
|
+
def host
|
69
|
+
@request.http2.host
|
70
|
+
end
|
71
|
+
|
72
|
+
def port
|
73
|
+
@request.http2.port
|
74
|
+
end
|
75
|
+
|
76
|
+
def ssl?
|
77
|
+
@request.http2.ssl?
|
78
|
+
end
|
79
|
+
|
80
|
+
def path
|
81
|
+
@request.path
|
82
|
+
end
|
83
|
+
|
84
|
+
def to_s
|
85
|
+
"#<Http::Response host=\"#{host}\" port=#{port} ssl=#{ssl?} path=\"#{path}\">"
|
86
|
+
end
|
87
|
+
|
88
|
+
def inspect
|
89
|
+
to_s
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
# Checks that the length of the body is the same as the given content-length if given.
|
95
|
+
def validate_body_versus_content_length!
|
96
|
+
unless header?("content-length")
|
97
|
+
puts "Http2: No content length given - skipping length validation." if @debug
|
98
|
+
return nil
|
99
|
+
end
|
100
|
+
|
101
|
+
content_length = header("content-length").to_i
|
102
|
+
body_length = @body.bytesize
|
103
|
+
|
104
|
+
puts "Http2: Body length: #{body_length}" if @debug
|
105
|
+
puts "Http2: Content length: #{content_length}" if @debug
|
106
|
+
|
107
|
+
raise "Body does not match the given content-length: '#{body_length}', '#{content_length}'." if body_length != content_length
|
108
|
+
end
|
109
|
+
end
|
@@ -1,17 +1,21 @@
|
|
1
1
|
class Http2::ResponseReader
|
2
2
|
attr_reader :response
|
3
3
|
|
4
|
-
def initialize(args)
|
4
|
+
def initialize(args:, http2:, sock:, request:)
|
5
5
|
@mode = "headers"
|
6
6
|
@transfer_encoding = nil
|
7
|
-
@
|
7
|
+
@request = request
|
8
|
+
@response = Http2::Response.new(debug: http2.debug, request: request)
|
8
9
|
@rec_count = 0
|
9
|
-
@args
|
10
|
+
@args = args
|
11
|
+
@debug = http2.debug
|
12
|
+
@http2 = http2
|
13
|
+
@sock = sock
|
10
14
|
@nl = @http2.nl
|
11
15
|
@conn = @http2.connection
|
12
16
|
|
13
17
|
read_headers
|
14
|
-
read_body if @length == nil || @length
|
18
|
+
read_body if @length == nil || @length.positive?
|
15
19
|
finish
|
16
20
|
end
|
17
21
|
|
@@ -23,6 +27,7 @@ class Http2::ResponseReader
|
|
23
27
|
if line == "\n" || line == "\r\n" || line == @nl
|
24
28
|
puts "Http2: Changing mode to body!" if @debug
|
25
29
|
raise "No headers was given at all? Possibly corrupt state after last request?" if @response.headers.empty?
|
30
|
+
|
26
31
|
@mode = "body"
|
27
32
|
@http2.on_content_call(@args, @nl)
|
28
33
|
break
|
@@ -49,24 +54,20 @@ class Http2::ResponseReader
|
|
49
54
|
end
|
50
55
|
|
51
56
|
def finish
|
52
|
-
#Check if we should reconnect based on keep-alive-max.
|
53
|
-
if @keepalive_max == 1 || @connection == "close"
|
54
|
-
@conn.close unless @conn.closed?
|
55
|
-
end
|
57
|
+
# Check if we should reconnect based on keep-alive-max.
|
58
|
+
@conn.close if !@conn.closed? && (@keepalive_max == 1 || @connection == "close")
|
56
59
|
|
57
60
|
# Validate that the response is as it should be.
|
58
61
|
puts "Http2: Validating response." if @debug
|
59
62
|
|
60
|
-
|
61
|
-
raise "No status-code was received from the server. Headers: '#{@response.headers}' Body: '#{resp.body}'."
|
62
|
-
end
|
63
|
+
raise "No status-code was received from the server. Headers: '#{@response.headers}' Body: '#{@response.body}'." unless @response.code
|
63
64
|
|
64
65
|
@response.validate!
|
65
66
|
check_and_decode
|
66
67
|
@http2.autostate_register(@response) if @http2.args[:autostate]
|
67
68
|
handle_errors
|
68
69
|
|
69
|
-
if response = check_and_follow_redirect
|
70
|
+
if (response = check_and_follow_redirect)
|
70
71
|
@response = response
|
71
72
|
end
|
72
73
|
end
|
@@ -74,27 +75,49 @@ class Http2::ResponseReader
|
|
74
75
|
private
|
75
76
|
|
76
77
|
def check_and_follow_redirect
|
77
|
-
if
|
78
|
+
if redirect_response?
|
78
79
|
url, args = url_and_args_from_location
|
79
80
|
|
80
|
-
if
|
81
|
-
|
81
|
+
if redirect_using_same_connection?(args)
|
82
|
+
@http2.get(url)
|
82
83
|
else
|
83
84
|
::Http2.new(args).get(url)
|
84
85
|
end
|
85
86
|
end
|
86
87
|
end
|
87
88
|
|
89
|
+
REDIRECT_CODES = [301, 302, 303, 307, 308].freeze
|
90
|
+
def redirect_response?
|
91
|
+
REDIRECT_CODES.include?(response.code.to_i) && response.header?("location") && @http2.args[:follow_redirects]
|
92
|
+
end
|
93
|
+
|
94
|
+
def redirect_using_same_connection?(args)
|
95
|
+
if !args[:host] || args[:host] == @args[:host]
|
96
|
+
true
|
97
|
+
else
|
98
|
+
false
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def url
|
103
|
+
@url ||= response.header("location")
|
104
|
+
end
|
105
|
+
|
88
106
|
def url_and_args_from_location
|
89
|
-
uri = URI.parse(
|
107
|
+
uri = URI.parse(url)
|
108
|
+
|
90
109
|
url = uri.path
|
91
|
-
url << "?#{uri.query}"
|
110
|
+
url << "?#{uri.query}" unless uri.query.to_s.empty?
|
111
|
+
url = url.gsub(/\A\//, "")
|
112
|
+
|
113
|
+
args = @http2.args
|
114
|
+
.reject { |k, _v| [:ssl, :port].include? k }
|
115
|
+
.merge(host: uri.host)
|
92
116
|
|
93
|
-
args = {host: uri.host}
|
94
117
|
args[:ssl] = true if uri.scheme == "https"
|
95
118
|
args[:port] = uri.port if uri.port
|
96
119
|
|
97
|
-
|
120
|
+
[url, args]
|
98
121
|
end
|
99
122
|
|
100
123
|
def check_and_decode
|
@@ -109,8 +132,8 @@ private
|
|
109
132
|
|
110
133
|
begin
|
111
134
|
valid_string = ic.encode("UTF-8")
|
112
|
-
rescue
|
113
|
-
valid_string = untrusted_str.force_encoding("UTF-8").encode("UTF-8", :
|
135
|
+
rescue StandardError
|
136
|
+
valid_string = untrusted_str.force_encoding("UTF-8").encode("UTF-8", invalid: :replace, replace: "").encode("UTF-8")
|
114
137
|
end
|
115
138
|
|
116
139
|
@response.body = valid_string
|
@@ -118,16 +141,21 @@ private
|
|
118
141
|
end
|
119
142
|
|
120
143
|
def handle_errors
|
121
|
-
return unless
|
144
|
+
return unless @http2.raise_errors
|
122
145
|
|
123
|
-
|
146
|
+
case @response.code
|
147
|
+
when "500"
|
124
148
|
err = Http2::Errors::Internalserver.new("A internal server error occurred")
|
125
|
-
|
149
|
+
when "403"
|
126
150
|
err = Http2::Errors::Noaccess.new("No access")
|
127
|
-
|
151
|
+
when "400"
|
128
152
|
err = Http2::Errors::Badrequest.new("Bad request")
|
129
|
-
|
153
|
+
when "401"
|
154
|
+
err = Http2::Errors::Unauthorized.new("Unauthorized")
|
155
|
+
when "404"
|
130
156
|
err = Http2::Errors::Notfound.new("Not found")
|
157
|
+
when "415"
|
158
|
+
err = Http2::Errors::UnsupportedMediaType.new("Unsupported media type")
|
131
159
|
end
|
132
160
|
|
133
161
|
if err
|
@@ -140,13 +168,19 @@ private
|
|
140
168
|
if line
|
141
169
|
@rec_count += line.length
|
142
170
|
elsif !line && @rec_count <= 0
|
143
|
-
|
171
|
+
parts = [
|
172
|
+
"KeepAliveMax: '#{@http2.keepalive_max}'",
|
173
|
+
"Connection: '#{@connection}'",
|
174
|
+
"PID: '#{Process.pid}'"
|
175
|
+
]
|
176
|
+
|
177
|
+
raise Errno::ECONNABORTED, "Server closed the connection before being able to read anything (#{parts.join(", ")})."
|
144
178
|
end
|
145
179
|
end
|
146
180
|
|
147
181
|
def parse_cookie(cookie_line)
|
148
|
-
::Http2::Utils.parse_set_cookies(cookie_line).each do |
|
149
|
-
@http2.cookies[
|
182
|
+
::Http2::Utils.parse_set_cookies(cookie_line).each do |cookie|
|
183
|
+
@http2.cookies[cookie.name] = cookie
|
150
184
|
end
|
151
185
|
end
|
152
186
|
|
@@ -163,24 +197,24 @@ private
|
|
163
197
|
end
|
164
198
|
|
165
199
|
def parse_content_type(content_type_line)
|
166
|
-
if match_charset = content_type_line.match(/\s*;\s*charset=(.+)/i)
|
200
|
+
if (match_charset = content_type_line.match(/\s*;\s*charset=(.+)/i))
|
167
201
|
@charset = match_charset[1].downcase
|
168
202
|
@response.charset = @charset
|
169
203
|
content_type_line.gsub!(match_charset[0], "")
|
170
204
|
end
|
171
205
|
|
172
|
-
@response.content_type =
|
206
|
+
@response.content_type = content_type_line
|
173
207
|
end
|
174
208
|
|
175
|
-
#Parse a header-line and saves it on the object.
|
209
|
+
# Parse a header-line and saves it on the object.
|
176
210
|
#===Examples
|
177
211
|
# http.parse_header("Content-Type: text/html\r\n")
|
178
212
|
def parse_header(line)
|
179
|
-
if match = line.match(/^(.+?):\s*(.+)#{@nl}$/)
|
213
|
+
if (match = line.match(/^(.+?):\s*(.+)#{@nl}$/))
|
180
214
|
key = match[1].downcase
|
181
215
|
set_header_special_values(key, match[2])
|
182
216
|
parse_normal_header(line, key, match[1], match[2])
|
183
|
-
elsif match = line.match(/^HTTP\/([\d\.]+)\s+(\d+)\s+(.+)$/)
|
217
|
+
elsif (match = line.match(/^HTTP\/([\d\.]+)\s+(\d+)\s+(.+)$/)) # rubocop:disable Style/RedundantRegexpEscape
|
184
218
|
@response.code = match[2]
|
185
219
|
@response.http_version = match[1]
|
186
220
|
@http2.on_content_call(@args, line)
|
@@ -211,18 +245,16 @@ private
|
|
211
245
|
@response.headers[key] = [] unless @response.headers.key?(key)
|
212
246
|
@response.headers[key] << value
|
213
247
|
|
214
|
-
if key != "transfer-encoding" && key != "content-length" && key != "connection" && key != "keep-alive"
|
215
|
-
@http2.on_content_call(@args, line)
|
216
|
-
end
|
248
|
+
@http2.on_content_call(@args, line) if key != "transfer-encoding" && key != "content-length" && key != "connection" && key != "keep-alive"
|
217
249
|
end
|
218
250
|
|
219
|
-
#Parses the body based on given headers and saves it to the result-object.
|
251
|
+
# Parses the body based on given headers and saves it to the result-object.
|
220
252
|
# http.parse_body(str)
|
221
253
|
def parse_body(line)
|
222
|
-
return :break if @length
|
254
|
+
return :break if @length&.zero?
|
223
255
|
|
224
256
|
if @transfer_encoding == "chunked"
|
225
|
-
|
257
|
+
parse_body_chunked(line)
|
226
258
|
else
|
227
259
|
puts "Http2: Adding #{line.to_s.bytesize} to the body." if @debug
|
228
260
|
@response.body << line
|
@@ -234,15 +266,16 @@ private
|
|
234
266
|
def parse_body_chunked(line)
|
235
267
|
len = line.strip.hex
|
236
268
|
|
237
|
-
if len
|
269
|
+
if len.positive?
|
238
270
|
read = @conn.read(len)
|
239
271
|
return :break if read == "" || read == "\n" || read == "\r\n"
|
272
|
+
|
240
273
|
@response.body << read
|
241
274
|
@http2.on_content_call(@args, read)
|
242
275
|
end
|
243
276
|
|
244
277
|
nl = @conn.gets
|
245
|
-
if len
|
278
|
+
if len.zero?
|
246
279
|
if nl == "\n" || nl == "\r\n"
|
247
280
|
return :break
|
248
281
|
else
|
@@ -1,14 +1,14 @@
|
|
1
1
|
class Http2::UrlBuilder
|
2
2
|
attr_accessor :host, :port, :protocol, :path, :params
|
3
3
|
|
4
|
-
def initialize
|
4
|
+
def initialize
|
5
5
|
@params = {}
|
6
6
|
end
|
7
7
|
|
8
8
|
def build_params
|
9
9
|
url_params = ""
|
10
10
|
|
11
|
-
|
11
|
+
unless params.empty?
|
12
12
|
first = true
|
13
13
|
|
14
14
|
params.each do |key, val|
|
@@ -24,18 +24,18 @@ class Http2::UrlBuilder
|
|
24
24
|
end
|
25
25
|
end
|
26
26
|
|
27
|
-
|
27
|
+
url_params
|
28
28
|
end
|
29
29
|
|
30
30
|
def build_path_and_params
|
31
|
-
url =
|
31
|
+
url = path.to_s
|
32
32
|
|
33
33
|
if params?
|
34
34
|
url << "?"
|
35
35
|
url << build_params
|
36
36
|
end
|
37
37
|
|
38
|
-
|
38
|
+
url
|
39
39
|
end
|
40
40
|
|
41
41
|
def build
|
@@ -49,7 +49,7 @@ class Http2::UrlBuilder
|
|
49
49
|
|
50
50
|
url << build_path_and_params
|
51
51
|
|
52
|
-
|
52
|
+
url
|
53
53
|
end
|
54
54
|
|
55
55
|
def params?
|
data/lib/http2/utils.rb
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
# This class holds various methods for encoding, decoding and parsing of HTTP-related stuff.
|
2
|
+
class Http2::Utils
|
3
|
+
# URL-encodes a string.
|
4
|
+
def self.urlenc(string)
|
5
|
+
# Thanks to CGI framework
|
6
|
+
string.to_s.gsub(/([^ a-zA-Z0-9_.-]+)/) do
|
7
|
+
"%#{Regexp.last_match(1).unpack("H2" * Regexp.last_match(1).bytesize).join("%").upcase}"
|
8
|
+
end.tr(" ", "+")
|
9
|
+
end
|
10
|
+
|
11
|
+
# URL-decodes a string.
|
12
|
+
def self.urldec(string)
|
13
|
+
# Thanks to CGI framework
|
14
|
+
string.to_s.tr("+", " ").gsub(/((?:%[0-9a-fA-F]{2})+)/) do
|
15
|
+
[Regexp.last_match(1).delete("%")].pack("H*")
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# Parses a cookies-string and returns them in an array.
|
20
|
+
def self.parse_set_cookies(str)
|
21
|
+
str = str.to_s
|
22
|
+
return [] if str.empty?
|
23
|
+
|
24
|
+
cookie_start_regex = /^(.+?)=(.*?)(;\s*|$)/
|
25
|
+
|
26
|
+
match = str.match(cookie_start_regex)
|
27
|
+
raise "Could not match cookie: '#{str}'" unless match
|
28
|
+
|
29
|
+
str.gsub!(cookie_start_regex, "")
|
30
|
+
|
31
|
+
cookie_data = {
|
32
|
+
name: urldec(match[1].to_s),
|
33
|
+
value: urldec(match[2].to_s)
|
34
|
+
}
|
35
|
+
|
36
|
+
while (match = str.match(/(.+?)=(.*?)(;\s*|$)/))
|
37
|
+
str = str.gsub(match[0], "")
|
38
|
+
key = match[1].to_s.downcase
|
39
|
+
value = match[2].to_s
|
40
|
+
|
41
|
+
if key == "path" || key == "expires"
|
42
|
+
cookie_data[key.to_sym] = value
|
43
|
+
else
|
44
|
+
cookie_data[key] = value
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
cookie = Http2::Cookie.new(cookie_data)
|
49
|
+
|
50
|
+
[cookie]
|
51
|
+
end
|
52
|
+
end
|