client-api-builder 0.8.0 → 0.10.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +114 -4
- data/lib/client-api-builder.rb +6 -0
- data/lib/client_api_builder/connection_pools/connection.rb +36 -0
- data/lib/client_api_builder/connection_pools/pool.rb +153 -0
- data/lib/client_api_builder/connection_pools/pool_set.rb +52 -0
- data/lib/client_api_builder/connection_pools/settings.rb +34 -0
- data/lib/client_api_builder/connection_pools.rb +57 -0
- data/lib/client_api_builder/http2/connection.rb +187 -0
- data/lib/client_api_builder/http2/connection_set.rb +61 -0
- data/lib/client_api_builder/http2/exchange.rb +76 -0
- data/lib/client_api_builder/http2/request_headers.rb +38 -0
- data/lib/client_api_builder/http2/response_body.rb +54 -0
- data/lib/client_api_builder/http2/response_builder.rb +45 -0
- data/lib/client_api_builder/http2/tls_socket.rb +80 -0
- data/lib/client_api_builder/http2.rb +69 -0
- data/lib/client_api_builder/nested_router.rb +20 -0
- data/lib/client_api_builder/net_http_request.rb +7 -1
- data/lib/client_api_builder/router.rb +40 -6
- data/lib/client_api_builder/section.rb +6 -0
- data/lib/client_api_builder/version.rb +1 -1
- metadata +14 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
|
|
6
|
+
module ClientApiBuilder
|
|
7
|
+
module HTTP2
|
|
8
|
+
# One HTTP/2 connection carrying concurrent requests as streams. A reader thread feeds the
|
|
9
|
+
# socket's bytes to the http-2 client, whose callbacks hand each stream's events to its
|
|
10
|
+
# Exchange. Every call into the http-2 client, and so every socket write, happens under one
|
|
11
|
+
# mutex; the reader blocks in readpartial outside it. (OpenSSL never reads and writes at
|
|
12
|
+
# once: Ruby holds the GVL while calling SSL_read and SSL_write.)
|
|
13
|
+
#
|
|
14
|
+
# A connection stops taking new streams once either side sends GOAWAY or it fails, and
|
|
15
|
+
# closes its socket when its last stream finishes.
|
|
16
|
+
class Connection
|
|
17
|
+
READ_SIZE = 16_384
|
|
18
|
+
|
|
19
|
+
# Returns nil, after closing the socket, when the server chose HTTP/1.1
|
|
20
|
+
def self.open(host, port, authority, connection_options)
|
|
21
|
+
socket = TLSSocket.open(host, port, connection_options)
|
|
22
|
+
return new(socket, authority, connection_options[:read_timeout]) if socket.alpn_protocol == 'h2'
|
|
23
|
+
|
|
24
|
+
socket.close
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(socket, authority, read_timeout)
|
|
29
|
+
@socket = socket
|
|
30
|
+
@authority = authority
|
|
31
|
+
@read_timeout = read_timeout
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@stream_closed = ConditionVariable.new
|
|
34
|
+
@exchanges = {}
|
|
35
|
+
@error = nil
|
|
36
|
+
@client = build_client
|
|
37
|
+
@mutex.synchronize { @client.send_connection_preface }
|
|
38
|
+
@reader = Thread.new { read_loop }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Sends a Net::HTTP request and, like Net::HTTP#request, yields the response before its
|
|
42
|
+
# body is read, then reads whatever the block left and returns the response
|
|
43
|
+
def request(net_request)
|
|
44
|
+
exchange = start(net_request)
|
|
45
|
+
response = ResponseBuilder.build(exchange.response_headers, exchange, net_request)
|
|
46
|
+
yield response if block_given?
|
|
47
|
+
response.read_body
|
|
48
|
+
response
|
|
49
|
+
ensure
|
|
50
|
+
cancel(exchange) if exchange
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# True while the connection can take new streams
|
|
54
|
+
def open?
|
|
55
|
+
@mutex.synchronize { @error.nil? && !@client.closed? }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Says goodbye to the server and closes the socket; streams still in flight fail
|
|
59
|
+
def close
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
send_goaway unless @error || @client.closed?
|
|
62
|
+
abort(ConnectionLost.new("HTTP/2 connection to #{@authority} was closed"))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def build_client
|
|
69
|
+
client = ::HTTP2::Client.new
|
|
70
|
+
client.on(:frame) { |bytes| @socket.write(bytes) }
|
|
71
|
+
client.on(:goaway) { |last_stream_id, _error, _payload| refuse_streams_after(last_stream_id) }
|
|
72
|
+
client
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def start(net_request)
|
|
76
|
+
headers = RequestHeaders.build(net_request, @authority)
|
|
77
|
+
body = net_request.body
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
stream = open_stream
|
|
80
|
+
exchange = @exchanges[stream.id] = Exchange.new(stream, @read_timeout)
|
|
81
|
+
stream.on(:close) { stream_closed(stream.id) }
|
|
82
|
+
stream.headers(headers, end_stream: body.nil?)
|
|
83
|
+
stream.data(body) if body
|
|
84
|
+
exchange
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Waits, up to read_timeout, while the server's limit on concurrent streams is reached
|
|
89
|
+
def open_stream
|
|
90
|
+
deadline = @read_timeout && (now + @read_timeout)
|
|
91
|
+
loop do
|
|
92
|
+
raise_if_failed!
|
|
93
|
+
return @client.new_stream
|
|
94
|
+
rescue ::HTTP2::Error::StreamLimitExceeded
|
|
95
|
+
wait_for_stream_slot(deadline)
|
|
96
|
+
end
|
|
97
|
+
rescue ::HTTP2::Error::ConnectionClosed
|
|
98
|
+
raise StreamRefused, "HTTP/2 connection to #{@authority} is no longer taking new streams"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def wait_for_stream_slot(deadline)
|
|
102
|
+
remaining = deadline && (deadline - now)
|
|
103
|
+
if remaining && !remaining.positive?
|
|
104
|
+
raise Net::ReadTimeout, "no HTTP/2 stream to #{@authority} became available within #{@read_timeout}s"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
@stream_closed.wait(@mutex, remaining)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def raise_if_failed!
|
|
111
|
+
raise @error.exception(@error.message) if @error
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Resets a stream the caller gave up on (it raised or timed out) so the server stops sending.
|
|
115
|
+
# Streams that finished, were refused or failed with the connection are no longer tracked.
|
|
116
|
+
def cancel(exchange)
|
|
117
|
+
@mutex.synchronize do
|
|
118
|
+
exchange.stream.cancel if @exchanges.key?(exchange.stream.id)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The peer may already be gone; the socket is closed next either way
|
|
123
|
+
def send_goaway
|
|
124
|
+
@client.goaway
|
|
125
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
126
|
+
::ClientApiBuilder.logger&.warn("HTTP/2 connection to #{@authority} failed to send GOAWAY: #{e.message}")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Called from the reader thread, holding the mutex
|
|
130
|
+
def stream_closed(stream_id)
|
|
131
|
+
@exchanges.delete(stream_id)
|
|
132
|
+
@stream_closed.broadcast
|
|
133
|
+
close_if_drained
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Once no new streams may open, the socket closes with the last stream, which ends the reader
|
|
137
|
+
def close_if_drained
|
|
138
|
+
@socket.close if @client.closed? && @exchanges.empty?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Streams the server will never process after GOAWAY are safe to retry elsewhere
|
|
142
|
+
def refuse_streams_after(last_stream_id)
|
|
143
|
+
@exchanges.select { |id, _| id > last_stream_id }.each do |id, exchange|
|
|
144
|
+
@exchanges.delete(id)
|
|
145
|
+
exchange.fail(StreamRefused.new("HTTP/2 server at #{@authority} went away before stream #{id} was processed"))
|
|
146
|
+
end
|
|
147
|
+
@stream_closed.broadcast
|
|
148
|
+
close_if_drained
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def read_loop
|
|
152
|
+
loop do
|
|
153
|
+
data = @socket.readpartial(READ_SIZE)
|
|
154
|
+
@mutex.synchronize { @client << data }
|
|
155
|
+
end
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
fail_connection(e)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def fail_connection(exception)
|
|
161
|
+
@mutex.synchronize { abort(connection_error(exception)) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Holding the mutex: fails every stream in flight and closes the socket. The first error
|
|
165
|
+
# sticks, so closing the socket (which ends the reader) doesn't replace the real cause.
|
|
166
|
+
def abort(error)
|
|
167
|
+
@error ||= error
|
|
168
|
+
@exchanges.each_value { |exchange| exchange.fail(@error.exception(@error.message)) }
|
|
169
|
+
@exchanges.clear
|
|
170
|
+
@stream_closed.broadcast
|
|
171
|
+
@socket.close
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def connection_error(exception)
|
|
175
|
+
if exception.is_a?(::HTTP2::Error::Error)
|
|
176
|
+
return ProtocolError.new("HTTP/2 protocol error from #{@authority}: #{exception.message}")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
ConnectionLost.new("HTTP/2 connection to #{@authority} lost: #{exception.message} (#{exception.class})")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def now
|
|
183
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClientApiBuilder
|
|
4
|
+
module HTTP2
|
|
5
|
+
# A client class's HTTP/2 connections: one per host, port and connection options, opened on
|
|
6
|
+
# first use and replaced once it stops taking new streams. Origins whose server chose
|
|
7
|
+
# HTTP/1.1 are remembered so they aren't asked again. After a fork the child starts empty;
|
|
8
|
+
# the parent's connections are dropped without being closed, since closing a shared TLS
|
|
9
|
+
# socket would disturb the parent.
|
|
10
|
+
class ConnectionSet
|
|
11
|
+
def initialize
|
|
12
|
+
reset
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns an open connection to the URI's origin, or nil when the server speaks only HTTP/1.1.
|
|
16
|
+
# A new connection is opened while holding the set's lock, so other origins wait for it.
|
|
17
|
+
def connection_for(uri, connection_options)
|
|
18
|
+
reset if forked?
|
|
19
|
+
key = [uri.hostname, uri.port, connection_options.dup.freeze].freeze
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
return nil if @http1_origins.include?(key)
|
|
22
|
+
|
|
23
|
+
connection = @connections[key]
|
|
24
|
+
connection&.open? ? connection : open_connection(key, uri, connection_options)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def close
|
|
29
|
+
connections = @mutex.synchronize do
|
|
30
|
+
@http1_origins.clear
|
|
31
|
+
@connections.values.tap { @connections.clear }
|
|
32
|
+
end
|
|
33
|
+
connections.each(&:close)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def reset
|
|
39
|
+
@pid = Process.pid
|
|
40
|
+
@mutex = Mutex.new
|
|
41
|
+
@connections = {}
|
|
42
|
+
@http1_origins = Set.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def forked?
|
|
46
|
+
@pid != Process.pid
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def open_connection(key, uri, connection_options)
|
|
50
|
+
connection = Connection.open(uri.hostname, uri.port, authority(uri), connection_options)
|
|
51
|
+
connection ? @connections[key] = connection : @http1_origins << key
|
|
52
|
+
connection
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# host keeps an IPv6 address's brackets; the port is left out when it's the default
|
|
56
|
+
def authority(uri)
|
|
57
|
+
uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
|
|
5
|
+
module ClientApiBuilder
|
|
6
|
+
module HTTP2
|
|
7
|
+
# One request's stream, seen from the thread waiting on it. The connection's reader thread
|
|
8
|
+
# pushes the stream's events (headers, data, close) or a connection failure onto a queue;
|
|
9
|
+
# the waiting thread pops them, giving up after read_timeout seconds without one.
|
|
10
|
+
#
|
|
11
|
+
# Received data is acknowledged to the server as it arrives, so a consumer that reads
|
|
12
|
+
# slower than the server sends buffers the difference in memory.
|
|
13
|
+
class Exchange
|
|
14
|
+
attr_reader :stream
|
|
15
|
+
|
|
16
|
+
def initialize(stream, read_timeout)
|
|
17
|
+
@stream = stream
|
|
18
|
+
@read_timeout = read_timeout
|
|
19
|
+
@events = Thread::Queue.new
|
|
20
|
+
@finished = false
|
|
21
|
+
stream.on(:headers) { |headers| @events << [:headers, headers] }
|
|
22
|
+
stream.on(:data) { |chunk| @events << [:data, chunk] }
|
|
23
|
+
stream.on(:close) { |error| @events << [:close, error] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Called by the connection when the stream will never finish
|
|
27
|
+
def fail(error)
|
|
28
|
+
@events << [:error, error]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Waits for the final response headers, skipping informational (1xx) ones
|
|
32
|
+
def response_headers
|
|
33
|
+
loop do
|
|
34
|
+
type, value = next_event
|
|
35
|
+
raise stream_closed_error(value || :no_error) if type == :close
|
|
36
|
+
return value unless informational?(value)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Yields each chunk of the response body until the stream ends. Trailers are ignored.
|
|
41
|
+
def each_chunk
|
|
42
|
+
until @finished
|
|
43
|
+
type, value = next_event
|
|
44
|
+
yield value if type == :data
|
|
45
|
+
finish(value) if type == :close
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def next_event
|
|
52
|
+
type, value = @events.pop(timeout: @read_timeout)
|
|
53
|
+
raise Net::ReadTimeout, "no response on HTTP/2 stream #{stream.id} within #{@read_timeout}s" if type.nil?
|
|
54
|
+
raise value if type == :error
|
|
55
|
+
|
|
56
|
+
[type, value]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def finish(error)
|
|
60
|
+
raise stream_closed_error(error) unless error.nil? || error == :no_error
|
|
61
|
+
|
|
62
|
+
@finished = true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def informational?(headers)
|
|
66
|
+
headers.any? { |name, value| name == ':status' && value.start_with?('1') }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def stream_closed_error(error)
|
|
70
|
+
return StreamRefused.new("server refused HTTP/2 stream #{stream.id}") if error == :refused_stream
|
|
71
|
+
|
|
72
|
+
StreamError.new("HTTP/2 stream #{stream.id} closed before the response finished (#{error})")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClientApiBuilder
|
|
4
|
+
module HTTP2
|
|
5
|
+
# Turns a Net::HTTP request into an HTTP/2 header list: the pseudo-headers first, then the
|
|
6
|
+
# request's own headers (already lowercase) without the connection-specific ones HTTP/2
|
|
7
|
+
# forbids (RFC 9113 section 8.2.2). Host becomes :authority.
|
|
8
|
+
module RequestHeaders
|
|
9
|
+
CONNECTION_HEADERS = %w[connection host keep-alive proxy-connection transfer-encoding upgrade].freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def build(net_request, authority)
|
|
14
|
+
headers = pseudo_headers(net_request, authority)
|
|
15
|
+
net_request.each_header do |name, value|
|
|
16
|
+
headers << [name, value] unless connection_header?(name, value)
|
|
17
|
+
end
|
|
18
|
+
body = net_request.body
|
|
19
|
+
headers << ['content-length', body.bytesize.to_s] if body && !net_request.key?('content-length')
|
|
20
|
+
headers
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def pseudo_headers(net_request, authority)
|
|
24
|
+
[
|
|
25
|
+
[':method', net_request.method],
|
|
26
|
+
[':scheme', 'https'],
|
|
27
|
+
[':authority', net_request['host'] || authority],
|
|
28
|
+
[':path', net_request.path]
|
|
29
|
+
]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# te is allowed only as "trailers"
|
|
33
|
+
def connection_header?(name, value)
|
|
34
|
+
CONNECTION_HEADERS.include?(name) || (name == 'te' && value != 'trailers')
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'zlib'
|
|
5
|
+
|
|
6
|
+
module ClientApiBuilder
|
|
7
|
+
module HTTP2
|
|
8
|
+
# Extended onto a Net::HTTPResponse built by ResponseBuilder: read_body reads the HTTP/2
|
|
9
|
+
# stream instead of a socket, with Net::HTTP's semantics (into a string, a given buffer or a
|
|
10
|
+
# block, once). A body that isn't expected is drained and left nil.
|
|
11
|
+
module ResponseBody
|
|
12
|
+
def http2_body(exchange, inflate:, expected:)
|
|
13
|
+
@http2_exchange = exchange
|
|
14
|
+
@http2_inflate = inflate
|
|
15
|
+
@http2_body_expected = expected
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read_body(dest = nil, &block)
|
|
19
|
+
if @read
|
|
20
|
+
raise IOError, "#{self.class}#read_body called twice" if dest || block
|
|
21
|
+
|
|
22
|
+
return @body
|
|
23
|
+
end
|
|
24
|
+
raise ArgumentError, 'both arg and block given for HTTP method' if dest && block
|
|
25
|
+
|
|
26
|
+
@body = @http2_body_expected ? read_http2_body(body_destination(dest, block)) : drain
|
|
27
|
+
@read = true
|
|
28
|
+
@body
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def body_destination(dest, block)
|
|
34
|
+
return Net::ReadAdapter.new(block) if block
|
|
35
|
+
|
|
36
|
+
dest || String.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def read_http2_body(dest)
|
|
40
|
+
inflater = Zlib::Inflate.new(32 + Zlib::MAX_WBITS) if @http2_inflate
|
|
41
|
+
@http2_exchange.each_chunk { |chunk| dest << (inflater ? inflater.inflate(chunk) : chunk) }
|
|
42
|
+
dest << inflater.finish if inflater
|
|
43
|
+
dest
|
|
44
|
+
ensure
|
|
45
|
+
inflater&.close
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def drain
|
|
49
|
+
@http2_exchange.each_chunk { |_chunk| nil }
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
|
|
5
|
+
module ClientApiBuilder
|
|
6
|
+
module HTTP2
|
|
7
|
+
# Builds the Net::HTTPResponse for an HTTP/2 response, so code written against Net::HTTP
|
|
8
|
+
# (handle_response, UnexpectedResponse#response, response['header']) works unchanged.
|
|
9
|
+
# HTTP/2 has no reason phrase, so message is empty. Like Net::HTTP, a gzip or deflate body
|
|
10
|
+
# is inflated when Net::HTTP chose accept-encoding itself (request.decode_content); its
|
|
11
|
+
# content-encoding and content-length headers are then removed.
|
|
12
|
+
module ResponseBuilder
|
|
13
|
+
INFLATABLE_ENCODINGS = %w[gzip deflate].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def build(headers, exchange, net_request)
|
|
18
|
+
status = headers.assoc(':status').last
|
|
19
|
+
response = response_class(status).new('2.0', status, '')
|
|
20
|
+
headers.each { |name, value| response.add_field(name, value) unless name.start_with?(':') }
|
|
21
|
+
inflate = net_request.decode_content && inflatable?(response)
|
|
22
|
+
response.delete('content-encoding') if inflate
|
|
23
|
+
response.delete('content-length') if inflate
|
|
24
|
+
response.extend(ResponseBody)
|
|
25
|
+
response.http2_body(exchange, inflate: inflate, expected: body_expected?(response, net_request))
|
|
26
|
+
response
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def response_class(status)
|
|
30
|
+
Net::HTTPResponse::CODE_TO_OBJ[status] ||
|
|
31
|
+
Net::HTTPResponse::CODE_CLASS_TO_OBJ[status[0]] ||
|
|
32
|
+
Net::HTTPUnknownResponse
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def inflatable?(response)
|
|
36
|
+
INFLATABLE_ENCODINGS.include?(response['content-encoding']&.downcase)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A HEAD request or a 204/304 response has no body; Net::HTTP leaves it nil
|
|
40
|
+
def body_expected?(response, net_request)
|
|
41
|
+
net_request.response_body_permitted? && response.class.body_permitted?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'resolv'
|
|
6
|
+
require 'socket'
|
|
7
|
+
|
|
8
|
+
module ClientApiBuilder
|
|
9
|
+
module HTTP2
|
|
10
|
+
# Opens a TLS connection that offers h2 and http/1.1 through ALPN, applying Net::HTTP's
|
|
11
|
+
# SSL connection options and certificate checks. The caller reads alpn_protocol to see
|
|
12
|
+
# which one the server chose.
|
|
13
|
+
module TLSSocket
|
|
14
|
+
ALPN_PROTOCOLS = %w[h2 http/1.1].freeze
|
|
15
|
+
|
|
16
|
+
# Net::HTTP connection option => SSLContext attribute
|
|
17
|
+
SSL_OPTIONS = %i[
|
|
18
|
+
ca_file ca_path cert cert_store ciphers extra_chain_cert key min_version max_version
|
|
19
|
+
ssl_version verify_callback verify_depth verify_hostname verify_mode
|
|
20
|
+
].to_h { |name| [name, name] }.merge(ssl_timeout: :timeout).freeze
|
|
21
|
+
|
|
22
|
+
WAIT_EVENTS = { wait_readable: IO::READABLE, wait_writable: IO::WRITABLE }.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def open(host, port, connection_options)
|
|
27
|
+
context = ssl_context(connection_options)
|
|
28
|
+
tcp = Socket.tcp(host, port, connect_timeout: connection_options[:open_timeout])
|
|
29
|
+
begin
|
|
30
|
+
ssl = start_tls(tcp, host, context, connection_options[:open_timeout])
|
|
31
|
+
ssl.post_connection_check(host) if verify_hostname?(context)
|
|
32
|
+
ssl
|
|
33
|
+
rescue StandardError
|
|
34
|
+
tcp.close
|
|
35
|
+
raise
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ssl_context(connection_options)
|
|
40
|
+
params = SSL_OPTIONS.filter_map do |option, attribute|
|
|
41
|
+
[attribute, connection_options[option]] unless connection_options[option].nil?
|
|
42
|
+
end
|
|
43
|
+
OpenSSL::SSL::SSLContext.new.tap do |context|
|
|
44
|
+
context.set_params(params.to_h)
|
|
45
|
+
context.alpn_protocols = ALPN_PROTOCOLS
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def start_tls(tcp, host, context, timeout)
|
|
50
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
|
|
51
|
+
ssl.sync_close = true
|
|
52
|
+
ssl.hostname = host unless ip_address?(host)
|
|
53
|
+
handshake(ssl, timeout && (now + timeout))
|
|
54
|
+
ssl
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def handshake(ssl, deadline)
|
|
58
|
+
while (state = ssl.connect_nonblock(exception: false)).is_a?(Symbol)
|
|
59
|
+
remaining = deadline && (deadline - now)
|
|
60
|
+
next if ssl.to_io.wait(WAIT_EVENTS.fetch(state), remaining)
|
|
61
|
+
|
|
62
|
+
raise Net::OpenTimeout, 'TLS handshake timed out'
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Same rule as Net::HTTP: the certificate must match the host unless verification is off
|
|
67
|
+
def verify_hostname?(context)
|
|
68
|
+
context.verify_mode != OpenSSL::SSL::VERIFY_NONE && context.verify_hostname != false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ip_address?(host)
|
|
72
|
+
host.match?(Resolv::IPv4::Regex) || host.match?(Resolv::IPv6::Regex)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def now
|
|
76
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Purpose: opt-in HTTP/2 for https requests, using the http-2 gem (add gem 'http-2' to your
|
|
4
|
+
# Gemfile; it isn't a runtime dependency). Include after ClientApiBuilder::Router, and after
|
|
5
|
+
# ClientApiBuilder::ConnectionPools when using both:
|
|
6
|
+
#
|
|
7
|
+
# class MyClient
|
|
8
|
+
# include ClientApiBuilder::Router
|
|
9
|
+
# include ClientApiBuilder::HTTP2
|
|
10
|
+
#
|
|
11
|
+
# base_url 'https://api.example.com'
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Every instance of the class shares one connection per origin, carrying concurrent requests
|
|
15
|
+
# as streams. TLS negotiates the protocol (ALPN): when the server picks HTTP/1.1, the origin is
|
|
16
|
+
# remembered and its requests go through Net::HTTP (or the connection pools) instead. http
|
|
17
|
+
# URLs always use HTTP/1.1. Responses are Net::HTTPResponse objects with http_version '2.0'.
|
|
18
|
+
module ClientApiBuilder
|
|
19
|
+
module HTTP2
|
|
20
|
+
autoload :Connection, 'client_api_builder/http2/connection'
|
|
21
|
+
autoload :ConnectionSet, 'client_api_builder/http2/connection_set'
|
|
22
|
+
autoload :Exchange, 'client_api_builder/http2/exchange'
|
|
23
|
+
autoload :RequestHeaders, 'client_api_builder/http2/request_headers'
|
|
24
|
+
autoload :ResponseBody, 'client_api_builder/http2/response_body'
|
|
25
|
+
autoload :ResponseBuilder, 'client_api_builder/http2/response_builder'
|
|
26
|
+
autoload :TLSSocket, 'client_api_builder/http2/tls_socket'
|
|
27
|
+
|
|
28
|
+
# The connection dropped (or was closed) before the response finished
|
|
29
|
+
class ConnectionLost < ::ClientApiBuilder::RetryableError; end
|
|
30
|
+
|
|
31
|
+
# The server refused the stream, or announced it would stop (GOAWAY) before processing it
|
|
32
|
+
class StreamRefused < ::ClientApiBuilder::RetryableError; end
|
|
33
|
+
|
|
34
|
+
# The server reset the stream with an error code
|
|
35
|
+
class StreamError < ::ClientApiBuilder::Error; end
|
|
36
|
+
|
|
37
|
+
# The server broke the HTTP/2 protocol; the connection is closed
|
|
38
|
+
class ProtocolError < ::ClientApiBuilder::Error; end
|
|
39
|
+
|
|
40
|
+
def self.included(base)
|
|
41
|
+
unless base.include?(::ClientApiBuilder::Router)
|
|
42
|
+
raise ArgumentError, 'include ClientApiBuilder::Router before ClientApiBuilder::HTTP2'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
require_http2_gem
|
|
46
|
+
base.extend ClassMethods
|
|
47
|
+
base.redefine_class_method(:http2_connections, ConnectionSet.new)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.require_http2_gem
|
|
51
|
+
require 'http/2'
|
|
52
|
+
rescue LoadError
|
|
53
|
+
raise LoadError, "ClientApiBuilder::HTTP2 needs the http-2 gem; add gem 'http-2' to your Gemfile"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
module ClassMethods
|
|
57
|
+
# Closes this class's HTTP/2 connections, and its connection pools when it has them
|
|
58
|
+
def close_connections
|
|
59
|
+
http2_connections.close
|
|
60
|
+
super if defined?(super)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def with_http_connection(uri, connection_options, &)
|
|
65
|
+
connection = uri.scheme == 'https' && self.class.http2_connections.connection_for(uri, connection_options)
|
|
66
|
+
connection ? yield(connection) : super
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -44,6 +44,21 @@ module ClientApiBuilder
|
|
|
44
44
|
"Allowed: #{INHERITABLE_SETTINGS.map(&:inspect).join(', ')}"
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Gives this section its own connection pools (see ConnectionPools.connection_pool).
|
|
48
|
+
# Including ConnectionPools puts its connection_pool ahead of this one, so the call
|
|
49
|
+
# below configures the pools rather than recursing.
|
|
50
|
+
def self.connection_pool(**settings)
|
|
51
|
+
include ::ClientApiBuilder::ConnectionPools
|
|
52
|
+
|
|
53
|
+
connection_pool(**settings)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Closes the pools of any nested sections that have their own; a section with its own
|
|
57
|
+
# pools uses ConnectionPools.close_connections, which closes those as well
|
|
58
|
+
def self.close_connections
|
|
59
|
+
section_routers.each_value(&:close_connections)
|
|
60
|
+
end
|
|
61
|
+
|
|
47
62
|
def configured_headers
|
|
48
63
|
inherits_from_root?(:headers) ? root_router.configured_headers.merge(super) : super
|
|
49
64
|
end
|
|
@@ -64,6 +79,11 @@ module ClientApiBuilder
|
|
|
64
79
|
root_router.handle_response(response, options, &)
|
|
65
80
|
end
|
|
66
81
|
|
|
82
|
+
# Uses the root client's connections unless this section calls connection_pool
|
|
83
|
+
def with_http_connection(uri, connection_options, &)
|
|
84
|
+
root_router.with_http_connection(uri, connection_options, &)
|
|
85
|
+
end
|
|
86
|
+
|
|
67
87
|
private
|
|
68
88
|
|
|
69
89
|
def inherits_from_root?(setting)
|
|
@@ -43,13 +43,19 @@ module ClientApiBuilder
|
|
|
43
43
|
ssl_options = uri.scheme == 'https' ? DEFAULT_SECURE_OPTIONS.merge(use_ssl: true) : {}
|
|
44
44
|
merged_options = ssl_options.merge(connection_options)
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
with_http_connection(uri, merged_options) do |http|
|
|
47
47
|
http.request(request) do |response|
|
|
48
48
|
yield response if block_given?
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# Yields a started Net::HTTP session for one request. By default each request opens
|
|
54
|
+
# and closes its own connection; ConnectionPools overrides this to reuse pooled ones.
|
|
55
|
+
def with_http_connection(uri, connection_options, &)
|
|
56
|
+
Net::HTTP.start(uri.hostname, uri.port, connection_options, &)
|
|
57
|
+
end
|
|
58
|
+
|
|
53
59
|
# validate_response, when given, is called with the response before its body is streamed
|
|
54
60
|
# and raises to reject it. A rejected body is read into response.body instead of being
|
|
55
61
|
# streamed, so the error can still show it.
|