async-http 0.18.0 → 0.19.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/async-http.gemspec +1 -1
- data/lib/async/http/accept_encoding.rb +65 -0
- data/lib/async/http/body.rb +2 -244
- data/lib/async/http/body/buffered.rb +107 -0
- data/lib/async/http/body/chunked.rb +67 -0
- data/lib/async/http/body/deflate.rb +106 -0
- data/lib/async/http/body/fixed.rb +83 -0
- data/lib/async/http/body/inflate.rb +55 -0
- data/lib/async/http/body/readable.rb +73 -0
- data/lib/async/http/body/streamable.rb +52 -0
- data/lib/async/http/body/wrapper.rb +58 -0
- data/lib/async/http/body/writable.rb +81 -0
- data/lib/async/http/client.rb +14 -19
- data/lib/async/http/content_encoding.rb +71 -0
- data/lib/async/http/middleware.rb +55 -0
- data/lib/async/http/middleware/builder.rb +50 -0
- data/lib/async/http/protocol/http10.rb +13 -11
- data/lib/async/http/protocol/http11.rb +24 -14
- data/lib/async/http/protocol/http2.rb +62 -48
- data/lib/async/http/reference.rb +173 -0
- data/lib/async/http/relative_location.rb +75 -0
- data/lib/async/http/request.rb +8 -2
- data/lib/async/http/response.rb +12 -2
- data/lib/async/http/server.rb +4 -2
- data/lib/async/http/statistics.rb +131 -0
- data/lib/async/http/url_endpoint.rb +17 -1
- data/lib/async/http/version.rb +1 -1
- metadata +20 -5
- data/lib/async/http/deflate_body.rb +0 -124
@@ -0,0 +1,173 @@
|
|
1
|
+
# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
|
2
|
+
#
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
8
|
+
# furnished to do so, subject to the following conditions:
|
9
|
+
#
|
10
|
+
# The above copyright notice and this permission notice shall be included in
|
11
|
+
# all copies or substantial portions of the Software.
|
12
|
+
#
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
19
|
+
# THE SOFTWARE.
|
20
|
+
|
21
|
+
module Async
|
22
|
+
module HTTP
|
23
|
+
# A relative reference, excluding any authority.
|
24
|
+
class Reference
|
25
|
+
def initialize(path, query_string, fragment, parameters)
|
26
|
+
@path = path
|
27
|
+
@query_string = query_string
|
28
|
+
@fragment = fragment
|
29
|
+
@parameters = parameters
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.[] reference
|
33
|
+
if reference.is_a? self
|
34
|
+
return reference
|
35
|
+
else
|
36
|
+
return self.parse(reference)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Generate a reference from a path and user parameters. The path may contain a `#fragment` or `?query=parameters`.
|
41
|
+
def self.parse(path = '/', parameters = nil)
|
42
|
+
base, fragment = path.split('#', 2)
|
43
|
+
path, query_string = base.split('?', 2)
|
44
|
+
|
45
|
+
self.new(path, query_string, fragment, parameters)
|
46
|
+
end
|
47
|
+
|
48
|
+
# The path component, e.g. /foo/bar/index.html
|
49
|
+
attr :path
|
50
|
+
|
51
|
+
# The un-parsed query string, e.g. 'x=10&y=20'
|
52
|
+
attr :query_string
|
53
|
+
|
54
|
+
# A fragment, the part after the '#'
|
55
|
+
attr :fragment
|
56
|
+
|
57
|
+
# User supplied parameters that will be appended to the query part.
|
58
|
+
attr :parameters
|
59
|
+
|
60
|
+
def append(buffer)
|
61
|
+
if @query_string
|
62
|
+
buffer << escape_path(@path) << '?' << query_string
|
63
|
+
buffer << '&' << encode(@parameters) if @parameters
|
64
|
+
else
|
65
|
+
buffer << escape_path(@path)
|
66
|
+
buffer << '?' << encode(@parameters) if @parameters
|
67
|
+
end
|
68
|
+
|
69
|
+
if @fragment
|
70
|
+
buffer << '#' << escape(@fragment)
|
71
|
+
end
|
72
|
+
|
73
|
+
return buffer
|
74
|
+
end
|
75
|
+
|
76
|
+
def to_str
|
77
|
+
append(String.new)
|
78
|
+
end
|
79
|
+
|
80
|
+
alias to_s to_str
|
81
|
+
|
82
|
+
def + other
|
83
|
+
other = self.class[other]
|
84
|
+
|
85
|
+
self.class.new(
|
86
|
+
expand_path(self.path, other.path),
|
87
|
+
other.query_string,
|
88
|
+
other.fragment,
|
89
|
+
other.parameters,
|
90
|
+
)
|
91
|
+
end
|
92
|
+
|
93
|
+
def [] parameters
|
94
|
+
self.dup(nil, parameters)
|
95
|
+
end
|
96
|
+
|
97
|
+
def dup(path = nil, parameters = nil)
|
98
|
+
if parameters and @parameters
|
99
|
+
parameters = @parameters.merge(parameters)
|
100
|
+
else
|
101
|
+
parameters = @parameters
|
102
|
+
end
|
103
|
+
|
104
|
+
if path
|
105
|
+
path = @path + '/' + path
|
106
|
+
else
|
107
|
+
path = @path
|
108
|
+
end
|
109
|
+
|
110
|
+
self.class.new(path, @query_string, @fragment, parameters)
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def expand_path(base, relative)
|
116
|
+
if relative.start_with? '/'
|
117
|
+
return relative
|
118
|
+
else
|
119
|
+
path = base.split('/')
|
120
|
+
parts = relative.split('/')
|
121
|
+
|
122
|
+
parts.each do |part|
|
123
|
+
if part == '..'
|
124
|
+
path.pop
|
125
|
+
else
|
126
|
+
path << part
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
return path.join('/')
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# Escapes a generic string, using percent encoding.
|
135
|
+
def escape(string)
|
136
|
+
encoding = string.encoding
|
137
|
+
string.b.gsub(/([^a-zA-Z0-9_.\-]+)/) do |m|
|
138
|
+
'%' + m.unpack('H2' * m.bytesize).join('%').upcase
|
139
|
+
end.force_encoding(encoding)
|
140
|
+
end
|
141
|
+
|
142
|
+
# According to https://tools.ietf.org/html/rfc3986#section-3.3, we escape non-pchar.
|
143
|
+
NON_PCHAR = /([^a-zA-Z0-9_\-\.~!$&'()*+,;=:@\/]+)/.freeze
|
144
|
+
|
145
|
+
# Escapes a path
|
146
|
+
def escape_path(path)
|
147
|
+
encoding = path.encoding
|
148
|
+
path.b.gsub(NON_PCHAR) do |m|
|
149
|
+
'%' + m.unpack('H2' * m.bytesize).join('%').upcase
|
150
|
+
end.force_encoding(encoding)
|
151
|
+
end
|
152
|
+
|
153
|
+
# Encodes a hash or array into a query string
|
154
|
+
def encode(value, prefix = nil)
|
155
|
+
case value
|
156
|
+
when Array
|
157
|
+
value.map { |v|
|
158
|
+
encode(v, "#{prefix}[]")
|
159
|
+
}.join("&")
|
160
|
+
when Hash
|
161
|
+
value.map { |k, v|
|
162
|
+
encode(v, prefix ? "#{prefix}[#{escape(k.to_s)}]" : escape(k.to_s))
|
163
|
+
}.reject(&:empty?).join('&')
|
164
|
+
when nil
|
165
|
+
prefix
|
166
|
+
else
|
167
|
+
raise ArgumentError, "value must be a Hash" if prefix.nil?
|
168
|
+
"#{prefix}=#{escape(value.to_s)}"
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
|
2
|
+
#
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
8
|
+
# furnished to do so, subject to the following conditions:
|
9
|
+
#
|
10
|
+
# The above copyright notice and this permission notice shall be included in
|
11
|
+
# all copies or substantial portions of the Software.
|
12
|
+
#
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
19
|
+
# THE SOFTWARE.
|
20
|
+
|
21
|
+
require_relative 'client'
|
22
|
+
require_relative 'url_endpoint'
|
23
|
+
|
24
|
+
require_relative 'reference'
|
25
|
+
|
26
|
+
module Async
|
27
|
+
module HTTP
|
28
|
+
# A client wrapper which transparently handles both relative and absolute redirects to a given maximum number of hops.
|
29
|
+
class RelativeLocation < Middleware
|
30
|
+
DEFAULT_METHOD = 'GET'.freeze
|
31
|
+
|
32
|
+
def initialize(app, maximum_hops = 4)
|
33
|
+
super(app)
|
34
|
+
|
35
|
+
@maximum_hops = maximum_hops
|
36
|
+
end
|
37
|
+
|
38
|
+
# The maximum number of hops which will limit the number of redirects until an error is thrown.
|
39
|
+
attr :maximum_hops
|
40
|
+
|
41
|
+
def call(request)
|
42
|
+
hops = 0
|
43
|
+
|
44
|
+
# We need to cache the body as it might be submitted multiple times.
|
45
|
+
request.finish
|
46
|
+
|
47
|
+
while hops < @maximum_hops
|
48
|
+
response = super(request)
|
49
|
+
hops += 1
|
50
|
+
|
51
|
+
if response.redirection?
|
52
|
+
response.finish
|
53
|
+
|
54
|
+
location = response.headers['location']
|
55
|
+
uri = URI.parse(location)
|
56
|
+
|
57
|
+
if uri.absolute?
|
58
|
+
return response
|
59
|
+
else
|
60
|
+
request.path = Reference[request.path] + location
|
61
|
+
end
|
62
|
+
|
63
|
+
unless response.preserve_method?
|
64
|
+
request.method = DEFAULT_METHOD
|
65
|
+
end
|
66
|
+
else
|
67
|
+
return response
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
raise ArgumentError, "Redirected #{hops} times, exceeded maximum!"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
data/lib/async/http/request.rb
CHANGED
@@ -18,12 +18,18 @@
|
|
18
18
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
19
19
|
# THE SOFTWARE.
|
20
20
|
|
21
|
-
require_relative 'body'
|
21
|
+
require_relative 'body/buffered'
|
22
22
|
|
23
23
|
module Async
|
24
24
|
module HTTP
|
25
25
|
class Request < Struct.new(:authority, :method, :path, :version, :headers, :body)
|
26
|
-
|
26
|
+
prepend Body::Buffered::Reader
|
27
|
+
|
28
|
+
def self.[](method, path, headers, body)
|
29
|
+
body = Body::Buffered.wrap(body)
|
30
|
+
|
31
|
+
self.new(nil, method, path, nil, headers, body)
|
32
|
+
end
|
27
33
|
end
|
28
34
|
end
|
29
35
|
end
|
data/lib/async/http/response.rb
CHANGED
@@ -18,11 +18,13 @@
|
|
18
18
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
19
19
|
# THE SOFTWARE.
|
20
20
|
|
21
|
-
require_relative 'body'
|
21
|
+
require_relative 'body/buffered'
|
22
22
|
|
23
23
|
module Async
|
24
24
|
module HTTP
|
25
25
|
class Response < Struct.new(:version, :status, :reason, :headers, :body)
|
26
|
+
prepend Body::Buffered::Reader
|
27
|
+
|
26
28
|
def continue?
|
27
29
|
status == 100
|
28
30
|
end
|
@@ -43,7 +45,15 @@ module Async
|
|
43
45
|
status >= 400 && status < 600
|
44
46
|
end
|
45
47
|
|
46
|
-
|
48
|
+
def self.[](status, headers = {}, body = [])
|
49
|
+
body = Body::Buffered.wrap(body)
|
50
|
+
|
51
|
+
self.new(nil, status, nil, headers, body)
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.for_exception(exception)
|
55
|
+
Async::HTTP::Response[500, {'content-type' => 'text/plain'}, ["#{exception.class}: #{exception.message}"]]
|
56
|
+
end
|
47
57
|
end
|
48
58
|
end
|
49
59
|
end
|
data/lib/async/http/server.rb
CHANGED
@@ -21,6 +21,8 @@
|
|
21
21
|
require 'async/io/endpoint'
|
22
22
|
|
23
23
|
require_relative 'protocol'
|
24
|
+
require_relative 'response'
|
25
|
+
|
24
26
|
|
25
27
|
module Async
|
26
28
|
module HTTP
|
@@ -30,12 +32,12 @@ module Async
|
|
30
32
|
@protocol_class = protocol_class || endpoint.protocol
|
31
33
|
|
32
34
|
if block_given?
|
33
|
-
|
35
|
+
define_singleton_method(:handle_request, block)
|
34
36
|
end
|
35
37
|
end
|
36
38
|
|
37
39
|
def handle_request(request, peer, address)
|
38
|
-
[200, {
|
40
|
+
Response[200, {}, []]
|
39
41
|
end
|
40
42
|
|
41
43
|
def accept(peer, address, task: Task.current)
|
@@ -0,0 +1,131 @@
|
|
1
|
+
# Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
|
2
|
+
#
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
8
|
+
# furnished to do so, subject to the following conditions:
|
9
|
+
#
|
10
|
+
# The above copyright notice and this permission notice shall be included in
|
11
|
+
# all copies or substantial portions of the Software.
|
12
|
+
#
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
19
|
+
# THE SOFTWARE.
|
20
|
+
|
21
|
+
require_relative 'body/wrapper'
|
22
|
+
|
23
|
+
require 'async/clock'
|
24
|
+
|
25
|
+
module Async
|
26
|
+
module HTTP
|
27
|
+
class Statistics
|
28
|
+
def self.start
|
29
|
+
self.new(Clock.now)
|
30
|
+
end
|
31
|
+
|
32
|
+
def initialize(start_time)
|
33
|
+
@start_time = start_time
|
34
|
+
end
|
35
|
+
|
36
|
+
def wrap(response, &block)
|
37
|
+
response.body = Body::Statistics.new(@start_time, response.body, block)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
module Body
|
42
|
+
# Invokes a callback once the body has finished reading.
|
43
|
+
class Statistics < Wrapper
|
44
|
+
def initialize(start_time, body, callback)
|
45
|
+
super(body)
|
46
|
+
|
47
|
+
@bytesize = 0
|
48
|
+
|
49
|
+
@start_time = start_time
|
50
|
+
@first_chunk_time = nil
|
51
|
+
@end_time = nil
|
52
|
+
|
53
|
+
@callback = callback
|
54
|
+
end
|
55
|
+
|
56
|
+
attr :start_time
|
57
|
+
attr :first_chunk_time
|
58
|
+
attr :end_time
|
59
|
+
|
60
|
+
attr :bytesize
|
61
|
+
|
62
|
+
def total_duration
|
63
|
+
if @end_time
|
64
|
+
@end_time - @start_time
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def first_chunk_duration
|
69
|
+
if @first_chunk_time
|
70
|
+
@first_chunk_time - @start_time
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def stop(error)
|
75
|
+
complete_statistics(error)
|
76
|
+
|
77
|
+
super
|
78
|
+
end
|
79
|
+
|
80
|
+
def read
|
81
|
+
chunk = super
|
82
|
+
|
83
|
+
@first_chunk_time ||= Clock.now
|
84
|
+
|
85
|
+
if chunk
|
86
|
+
@bytesize += chunk.bytesize
|
87
|
+
else
|
88
|
+
complete_statistics
|
89
|
+
end
|
90
|
+
|
91
|
+
return chunk
|
92
|
+
end
|
93
|
+
|
94
|
+
def to_s
|
95
|
+
parts = ["sent #{@bytesize} bytes"]
|
96
|
+
|
97
|
+
if duration = self.total_duration
|
98
|
+
parts << "took #{format_duration(duration)} in total"
|
99
|
+
end
|
100
|
+
|
101
|
+
if duration = self.first_chunk_duration
|
102
|
+
parts << "took #{format_duration(duration)} until first chunk"
|
103
|
+
end
|
104
|
+
|
105
|
+
return parts.join('; ')
|
106
|
+
end
|
107
|
+
|
108
|
+
def inspect
|
109
|
+
"#{super} | \#<#{self.class} #{self.to_s}>"
|
110
|
+
end
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
def complete_statistics(error = nil)
|
115
|
+
@end_time = Clock.now
|
116
|
+
|
117
|
+
@callback.call(self, error) if @callback
|
118
|
+
end
|
119
|
+
|
120
|
+
def format_duration(seconds)
|
121
|
+
if seconds < 1.0
|
122
|
+
seconds * 1000.0
|
123
|
+
return "#{(seconds * 1000.0).round(2)}ms"
|
124
|
+
else
|
125
|
+
return "#{seconds.round(1)}s"
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|