async-http 0.18.0 → 0.19.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.
@@ -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