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.
@@ -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
@@ -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
- include BufferedBody::Reader
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
@@ -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
- include BufferedBody::Reader
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
@@ -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
- self.define_singleton_method(:handle_request, block)
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, {"Content-Type" => "text/plain"}, ["Hello World"]]
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