plum 0.1.3 → 0.2.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/README.md +84 -12
- data/circle.yml +27 -0
- data/examples/client/large.rb +20 -0
- data/examples/client/twitter.rb +51 -0
- data/examples/non_tls_server.rb +15 -9
- data/examples/static_server.rb +30 -23
- data/lib/plum.rb +9 -2
- data/lib/plum/client.rb +198 -0
- data/lib/plum/client/client_session.rb +91 -0
- data/lib/plum/client/connection.rb +19 -0
- data/lib/plum/client/legacy_client_session.rb +118 -0
- data/lib/plum/client/response.rb +100 -0
- data/lib/plum/client/upgrade_client_session.rb +46 -0
- data/lib/plum/connection.rb +58 -65
- data/lib/plum/connection_utils.rb +1 -1
- data/lib/plum/errors.rb +7 -3
- data/lib/plum/flow_control.rb +3 -3
- data/lib/plum/rack/listener.rb +3 -3
- data/lib/plum/rack/server.rb +1 -0
- data/lib/plum/rack/session.rb +5 -2
- data/lib/plum/server/connection.rb +42 -0
- data/lib/plum/{http_connection.rb → server/http_connection.rb} +7 -14
- data/lib/plum/{https_connection.rb → server/https_connection.rb} +2 -9
- data/lib/plum/stream.rb +54 -24
- data/lib/plum/stream_utils.rb +0 -12
- data/lib/plum/version.rb +1 -1
- data/plum.gemspec +2 -2
- data/test/plum/client/test_client.rb +152 -0
- data/test/plum/client/test_connection.rb +11 -0
- data/test/plum/client/test_legacy_client_session.rb +90 -0
- data/test/plum/client/test_response.rb +74 -0
- data/test/plum/client/test_upgrade_client_session.rb +45 -0
- data/test/plum/connection/test_handle_frame.rb +4 -1
- data/test/plum/{test_http_connection.rb → server/test_http_connection.rb} +4 -4
- data/test/plum/{test_https_connection.rb → server/test_https_connection.rb} +14 -8
- data/test/plum/test_connection.rb +9 -2
- data/test/plum/test_connection_utils.rb +9 -0
- data/test/plum/test_error.rb +1 -2
- data/test/plum/test_frame_factory.rb +37 -0
- data/test/plum/test_stream.rb +24 -4
- data/test/plum/test_stream_utils.rb +0 -1
- data/test/test_helper.rb +5 -2
- data/test/utils/assertions.rb +9 -9
- data/test/utils/client.rb +19 -0
- data/test/utils/server.rb +6 -6
- data/test/utils/string_socket.rb +15 -0
- metadata +36 -12
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4194e9fa59a660fc839ea356770a05a627dba684
|
4
|
+
data.tar.gz: 5b4f22bd734f7d7df5dca01667716ecc3dc4e51e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 987380963b7702bd5a6aa0f4ab817ae515f34cbd1b74ba48ea2b46f5e27169173c80821af49f353bc61363a634107290bc4a43abe599a21707cbaadbc6446f0f
|
7
|
+
data.tar.gz: bd645808edbcf763c896e00085ab3d69d89639f44b1de09c3ac39f7ed2c79d6c5f313f2791dad57c43d31d68eeb9b59e745ba3e4040835e5670543f9284c905e
|
data/README.md
CHANGED
@@ -1,5 +1,9 @@
|
|
1
|
-
# Plum
|
2
|
-
A
|
1
|
+
# Plum: An HTTP/2 Library for Ruby
|
2
|
+
A pure Ruby HTTP/2 server and client implementation.
|
3
|
+
|
4
|
+
WARNING: Plum is currently under heavy development. You *will* encounter bugs when using it.
|
5
|
+
|
6
|
+
[](https://circleci.com/gh/rhenium/plum) [](https://travis-ci.org/rhenium/plum) [](https://codeclimate.com/github/rhenium/plum) [](https://codeclimate.com/github/rhenium/plum/coverage)
|
3
7
|
|
4
8
|
## Requirements
|
5
9
|
* Ruby
|
@@ -7,18 +11,21 @@ A minimal pure Ruby implementation of HTTP/2 library / server.
|
|
7
11
|
* or latest Ruby 2.3.0-dev
|
8
12
|
* OpenSSL 1.0.2 or newer (HTTP/2 requires ALPN)
|
9
13
|
* Optional:
|
10
|
-
* [
|
11
|
-
* [rack gem](https://rubygems.org/gems/rack) if you use Plum as Rack server
|
14
|
+
* [http_parser.rb gem](https://rubygems.org/gems/http_parser.rb) (HTTP/1.x parser; if you use "http" URI scheme)
|
15
|
+
* [rack gem](https://rubygems.org/gems/rack) (if you use Plum as Rack server)
|
16
|
+
|
17
|
+
## Installation
|
18
|
+
```sh
|
19
|
+
gem install plum
|
20
|
+
```
|
12
21
|
|
13
22
|
## Usage
|
14
|
-
|
15
|
-
*
|
16
|
-
* See examples in `examples/`
|
17
|
-
* [rhenium/plum-server](https://github.com/rhenium/plum-server) - A static-file server for https://rhe.jp and http://rhe.jp.
|
23
|
+
* Documentation: http://www.rubydoc.info/gems/plum
|
24
|
+
* Some examples are in `examples/`
|
18
25
|
|
19
26
|
### As a Rack-compatible server
|
20
27
|
|
21
|
-
Most existing Rack-based applications
|
28
|
+
Most existing Rack-based applications should work without modification.
|
22
29
|
|
23
30
|
```ruby
|
24
31
|
# config.ru
|
@@ -26,7 +33,7 @@ App = -> env {
|
|
26
33
|
[
|
27
34
|
200,
|
28
35
|
{ "Content-Type" => "text/plain" },
|
29
|
-
["
|
36
|
+
["request: #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"]
|
30
37
|
]
|
31
38
|
}
|
32
39
|
|
@@ -36,13 +43,78 @@ run App
|
|
36
43
|
You can run it:
|
37
44
|
|
38
45
|
```sh
|
39
|
-
% plum -e production -p 8080 --https config.ru
|
46
|
+
% plum -e production -p 8080 --https --cert server.crt --key server.key config.ru
|
40
47
|
```
|
41
48
|
|
42
|
-
|
49
|
+
NOTE: If `--cert` and `--key` are omitted, a temporary dummy certificate will be generated.
|
50
|
+
|
51
|
+
### As a HTTP/2 (HTTP/1.x) client library
|
52
|
+
If the server does't support HTTP/2, `Plum::Client` tries to use HTTP/1.x instead.
|
53
|
+
|
54
|
+
```
|
55
|
+
+-----------------+
|
56
|
+
|:http2 option | false
|
57
|
+
|(default: true) |-------> HTTP/1.x
|
58
|
+
+-----------------+
|
59
|
+
v true
|
60
|
+
+-----------------+
|
61
|
+
|:scheme option | "http"
|
62
|
+
|(default:"https")|-------> Try Upgrade from HTTP/1.1
|
63
|
+
+-----------------+
|
64
|
+
v "https"
|
65
|
+
+-----------------+
|
66
|
+
| ALPN(/NPN) | failed
|
67
|
+
| negotiation |-------> HTTP/1.x
|
68
|
+
+-----------------+
|
69
|
+
| "h2"
|
70
|
+
v
|
71
|
+
HTTP/2
|
72
|
+
```
|
73
|
+
|
74
|
+
##### Sequential request
|
75
|
+
```ruby
|
76
|
+
client = Plum::Client.start("http2.rhe.jp", 443, user_agent: "nyaan")
|
77
|
+
res1 = client.get!("/", headers: { "accept" => "*/*" })
|
78
|
+
puts res1.body # => "..."
|
79
|
+
res2 = client.post!("/post", "data")
|
80
|
+
puts res2.body # => "..."
|
81
|
+
|
82
|
+
client.close
|
83
|
+
```
|
84
|
+
|
85
|
+
##### Parallel request
|
86
|
+
```ruby
|
87
|
+
res1 = res2 = nil
|
88
|
+
Plum::Client.start("rhe.jp", 443, http2_settings: { max_frame_size: 32768 }) { |client|
|
89
|
+
res1 = client.get("/")
|
90
|
+
res2 = client.post("/post", "data")
|
91
|
+
# res1.status == nil ; because it's async request
|
92
|
+
} # wait for response(s) and close
|
93
|
+
|
94
|
+
p res1.status # => "200"
|
95
|
+
```
|
96
|
+
|
97
|
+
##### Download a large file
|
98
|
+
```ruby
|
99
|
+
Plum::Client.start("http2.rhe.jp", 443, hostname: "assets.rhe.jp") { |client|
|
100
|
+
client.get("/large") do |res| # called when received response headers
|
101
|
+
p res.status # => "200"
|
102
|
+
File.open("/tmp/large.file", "wb") { |file|
|
103
|
+
res.on_chunk do |chunk| # called when each chunk of response body arrived
|
104
|
+
file << chunk
|
105
|
+
end
|
106
|
+
}
|
107
|
+
end
|
108
|
+
}
|
109
|
+
```
|
43
110
|
|
44
111
|
## TODO
|
45
112
|
* **Better API**
|
113
|
+
* Plum::Client
|
114
|
+
* PING frame handling
|
115
|
+
* Server Push support
|
116
|
+
* Stream Priority support
|
117
|
+
* Better HTTP/1.x support
|
46
118
|
|
47
119
|
## License
|
48
120
|
MIT License
|
data/circle.yml
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
machine:
|
2
|
+
environment:
|
3
|
+
CODECLIMATE_REPO_TOKEN: f5092ab344fac7f2de9d7332e00597642a4d24e3d560f7d7f329172a2e5a2def
|
4
|
+
dependencies:
|
5
|
+
pre:
|
6
|
+
- echo openssl_url=https://www.openssl.org/source >> $rvm_path/user/db
|
7
|
+
- echo openssl_version=1.0.2d >> $rvm_path/user/db
|
8
|
+
- rvm pkg install openssl
|
9
|
+
- >
|
10
|
+
case $CIRCLE_NODE_INDEX in
|
11
|
+
0)
|
12
|
+
rvm install 2.2.3-alpn --with-openssl-dir=$rvm_path/usr --patch https://gist.githubusercontent.com/rhenium/b1711edcc903e8887a51/raw/2309e469f5a3ba15917d804ac61b19e62b3d8faf/ruby-openssl-alpn-no-tests-and-docs.patch
|
13
|
+
rvm use 2.2.3-alpn --default
|
14
|
+
;;
|
15
|
+
1)
|
16
|
+
rvm install ruby-head --with-openssl-dir=$rvm_path/usr
|
17
|
+
rvm use ruby-head --default
|
18
|
+
;;
|
19
|
+
esac
|
20
|
+
override:
|
21
|
+
- gem install bundler
|
22
|
+
- bundle install
|
23
|
+
test:
|
24
|
+
override:
|
25
|
+
- $rvm_path/usr/bin/openssl version
|
26
|
+
- ruby -v
|
27
|
+
- bundle exec rake test
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# -*- frozen-string-literal: true -*-
|
2
|
+
# client/large.rb: download 3 large files in parallel
|
3
|
+
$LOAD_PATH.unshift File.expand_path("../../../lib", __FILE__)
|
4
|
+
require "plum"
|
5
|
+
|
6
|
+
def log(s)
|
7
|
+
puts "[#{Time.now.strftime("%Y/%m/%d %H:%M:%S.%L")}] #{s}"
|
8
|
+
end
|
9
|
+
|
10
|
+
Plum::Client.start("http2.golang.org", 443, http2_settings: { max_frame_size: 32768 }) { |rest|
|
11
|
+
3.times { |i|
|
12
|
+
rest.get("/file/go.src.tar.gz",
|
13
|
+
headers: { "accept-encoding" => "identity;q=1" }) { |res|
|
14
|
+
log "#{i}: #{res.status}"
|
15
|
+
res.on_chunk { |chunk|
|
16
|
+
log "#{i}: chunk: #{chunk.size}"
|
17
|
+
}
|
18
|
+
}
|
19
|
+
}
|
20
|
+
}
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# -*- frozen-string-literal: true -*-
|
2
|
+
# client/twitter.rb
|
3
|
+
# Twitter の User stream に(現在はサーバーが非対応のため)HTTP/1.1 を使用して接続する。
|
4
|
+
# 「にゃーん」を含むツイートを受信したら、REST API で HTTP/2 を使用して返信する。
|
5
|
+
$LOAD_PATH.unshift File.expand_path("../../../lib", __FILE__)
|
6
|
+
require "plum"
|
7
|
+
require "json"
|
8
|
+
require "cgi"
|
9
|
+
require "simple_oauth"
|
10
|
+
|
11
|
+
credentials = { consumer_key: "",
|
12
|
+
consumer_secret: "",
|
13
|
+
token: "",
|
14
|
+
token_secret: "" }
|
15
|
+
|
16
|
+
rest = Plum::Client.start("api.twitter.com", 443)
|
17
|
+
Plum::Client.start("userstream.twitter.com", 443) { |streaming|
|
18
|
+
streaming.get("/1.1/user.json",
|
19
|
+
headers: { "authorization" => SimpleOAuth::Header.new(:get, "https://userstream.twitter.com/1.1/user.json", {}, credentials).to_s,
|
20
|
+
"accept-encoding" => "identity;q=1" }) { |res| # plum doesn't have built-in gzip/deflate decoder
|
21
|
+
if res.status != "200"
|
22
|
+
puts "failed userstream"
|
23
|
+
exit
|
24
|
+
end
|
25
|
+
|
26
|
+
buf = String.new
|
27
|
+
res.on_chunk { |chunk| # when received DATA frame
|
28
|
+
buf << chunk
|
29
|
+
*msgs, buf = buf.split("\r\n", -1)
|
30
|
+
|
31
|
+
msgs.each do |msg|
|
32
|
+
next if msg.empty?
|
33
|
+
|
34
|
+
json = JSON.parse(msg)
|
35
|
+
next unless json["user"] # unless it is a tweet
|
36
|
+
|
37
|
+
puts "@#{json["user"]["screen_name"]}: #{json["text"]}"
|
38
|
+
|
39
|
+
if /にゃーん/ =~ json["text"]
|
40
|
+
args = { "status" => "@#{json["user"]["screen_name"]} にゃーん",
|
41
|
+
"in_reply_to_status_id" => json["id"].to_s }
|
42
|
+
rest.post!("/1.1/statuses/update.json",
|
43
|
+
args.map { |k, v| "#{k}=#{CGI.escape(v)}" }.join("&"),
|
44
|
+
headers: { "authorization" => SimpleOAuth::Header.new(:post, "https://api.twitter.com/1.1/statuses/update.json", args, credentials).to_s,
|
45
|
+
"content-type" => "application/x-www-form-urlencoded" })
|
46
|
+
end
|
47
|
+
end
|
48
|
+
}
|
49
|
+
}
|
50
|
+
}
|
51
|
+
rest.close
|
data/examples/non_tls_server.rb
CHANGED
@@ -25,7 +25,7 @@ loop do
|
|
25
25
|
next
|
26
26
|
end
|
27
27
|
|
28
|
-
plum = Plum::
|
28
|
+
plum = Plum::HTTPServerConnection.new(sock)
|
29
29
|
|
30
30
|
plum.on(:frame) do |frame|
|
31
31
|
log(id, frame.stream_id, "recv: #{frame.inspect}")
|
@@ -77,42 +77,48 @@ loop do
|
|
77
77
|
<input type=submit>
|
78
78
|
</form>
|
79
79
|
EOF
|
80
|
-
stream.
|
80
|
+
stream.send_headers({
|
81
81
|
":status": "200",
|
82
82
|
"server": "plum",
|
83
83
|
"content-type": "text/html",
|
84
84
|
"content-length": body.bytesize
|
85
|
-
},
|
85
|
+
}, end_stream: false)
|
86
|
+
stream.send_data(body, end_stream: true)
|
86
87
|
when ["POST", "/post.page"]
|
87
88
|
body = "Posted value is: #{CGI.unescape(data).gsub("<", "<").gsub(">", ">")}<br> <a href=/>Back to top page</a>"
|
88
|
-
stream.
|
89
|
+
stream.send_headers({
|
89
90
|
":status": "200",
|
90
91
|
"server": "plum",
|
91
92
|
"content-type": "text/html",
|
92
93
|
"content-length": body.bytesize
|
93
|
-
},
|
94
|
+
}, end_stream: false)
|
95
|
+
stream.send_data(body, end_stream: true)
|
94
96
|
else
|
95
97
|
body = "Page not found! <a href=/>Back to top page</a>"
|
96
|
-
stream.
|
98
|
+
stream.send_headers({
|
97
99
|
":status": "404",
|
98
100
|
"server": "plum",
|
99
101
|
"content-type": "text/html",
|
100
102
|
"content-length": body.bytesize
|
101
|
-
},
|
103
|
+
}, end_stream: false)
|
104
|
+
stream.send_data(body, end_stream: true)
|
102
105
|
end
|
103
106
|
end
|
104
107
|
end
|
105
108
|
|
106
109
|
Thread.new {
|
107
110
|
begin
|
108
|
-
|
111
|
+
while !sock.closed? && !sock.eof?
|
112
|
+
plum << sock.readpartial(1024)
|
113
|
+
end
|
109
114
|
rescue Plum::LegacyHTTPError
|
115
|
+
data = "Use modern web browser with HTTP/2 support."
|
110
116
|
resp = "HTTP/1.1 505 HTTP Version Not Supported\r\n"
|
111
117
|
"Content-Type: text/plain\r\n"
|
112
118
|
"Content-Length: #{data.bytesize}\r\n"
|
113
119
|
"Server: plum/#{Plum::VERSION}\r\n"
|
114
120
|
"\r\n"
|
115
|
-
"
|
121
|
+
"#{data}"
|
116
122
|
|
117
123
|
sock.write(resp)
|
118
124
|
rescue
|
data/examples/static_server.rb
CHANGED
@@ -43,7 +43,7 @@ loop do
|
|
43
43
|
next
|
44
44
|
end
|
45
45
|
|
46
|
-
plum = Plum::
|
46
|
+
plum = Plum::HTTPSServerConnection.new(sock)
|
47
47
|
|
48
48
|
plum.on(:frame) do |frame|
|
49
49
|
log(id, frame.stream_id, "recv: #{frame.inspect}")
|
@@ -95,59 +95,66 @@ loop do
|
|
95
95
|
<input type=submit>
|
96
96
|
</form>
|
97
97
|
EOF
|
98
|
-
stream.
|
98
|
+
stream.send_headers({
|
99
99
|
":status": "200",
|
100
100
|
"server": "plum",
|
101
101
|
"content-type": "text/html",
|
102
|
-
"content-length": body.
|
103
|
-
},
|
102
|
+
"content-length": body.bytesize
|
103
|
+
}, end_stream: false)
|
104
|
+
stream.send_data(body, end_stream: true)
|
104
105
|
when ["GET", "/abc.html"]
|
105
106
|
body = "ABC! <a href=/>Back to top page</a><br><img src=/image.nyan>"
|
107
|
+
stream.send_headers({
|
108
|
+
":status": "200",
|
109
|
+
"server": "plum",
|
110
|
+
"content-type": "text/html",
|
111
|
+
"content-length": body.bytesize
|
112
|
+
}, end_stream: false)
|
106
113
|
i_stream = stream.promise({
|
107
114
|
":authority": "localhost:40443",
|
108
115
|
":method": "GET",
|
109
116
|
":scheme": "https",
|
110
117
|
":path": "/image.nyan"
|
111
118
|
})
|
112
|
-
stream.
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
"content-length": body.size
|
117
|
-
}, body)
|
118
|
-
image = ("iVBORw0KGgoAAAANSUhEUgAAAEAAAABAAgMAAADXB5lNAAAACVBMVEX///93o0jG/4mTMy20AAAA"
|
119
|
-
"bklEQVQ4y2NgoAoIRQJkCoSimIdTgJGBBU1ABE1A1AVdBQuaACu6gCALhhZ0axlZCDgMWYAB6ilU"
|
120
|
-
"35IoADEMxWyyBDD45AhQCFahM0kXWIVu3sAJrILzyBcgytoFeATABBcXWohhCEC14BCgGAAAX1ZQ"
|
119
|
+
stream.send_data(body, end_stream: true)
|
120
|
+
image = ("iVBORw0KGgoAAAANSUhEUgAAAEAAAABAAgMAAADXB5lNAAAACVBMVEX///93o0jG/4mTMy20AAAA" \
|
121
|
+
"bklEQVQ4y2NgoAoIRQJkCoSimIdTgJGBBU1ABE1A1AVdBQuaACu6gCALhhZ0axlZCDgMWYAB6ilU" \
|
122
|
+
"35IoADEMxWyyBDD45AhQCFahM0kXWIVu3sAJrILzyBcgytoFeATABBcXWohhCEC14BCgGAAAX1ZQ" \
|
121
123
|
"ZtJp0zAAAAAASUVORK5CYII=").unpack("m")[0]
|
122
|
-
i_stream.
|
124
|
+
i_stream.send_headers({
|
123
125
|
":status": "200",
|
124
126
|
"server": "plum",
|
125
127
|
"content-type": "image/png",
|
126
|
-
"content-length": image.
|
127
|
-
},
|
128
|
+
"content-length": image.bytesize
|
129
|
+
}, end_stream: false)
|
130
|
+
i_stream.send_data(image, end_stream: true)
|
128
131
|
when ["POST", "/post.page"]
|
129
132
|
body = "Posted value is: #{CGI.unescape(data).gsub("<", "<").gsub(">", ">")}<br> <a href=/>Back to top page</a>"
|
130
|
-
stream.
|
133
|
+
stream.send_headers({
|
131
134
|
":status": "200",
|
132
135
|
"server": "plum",
|
133
136
|
"content-type": "text/html",
|
134
|
-
"content-length": body.
|
135
|
-
},
|
137
|
+
"content-length": body.bytesize
|
138
|
+
}, end_stream: false)
|
139
|
+
stream.send_data(body, end_stream: true)
|
136
140
|
else
|
137
141
|
body = "Page not found! <a href=/>Back to top page</a>"
|
138
|
-
stream.
|
142
|
+
stream.send_headers({
|
139
143
|
":status": "404",
|
140
144
|
"server": "plum",
|
141
145
|
"content-type": "text/html",
|
142
|
-
"content-length": body.
|
143
|
-
},
|
146
|
+
"content-length": body.bytesize
|
147
|
+
}, end_stream: false)
|
148
|
+
stream.send_data(body, end_stream: true)
|
144
149
|
end
|
145
150
|
end
|
146
151
|
end
|
147
152
|
|
148
153
|
Thread.new {
|
149
154
|
begin
|
150
|
-
|
155
|
+
while !sock.closed? && !sock.eof?
|
156
|
+
plum << sock.readpartial(1024)
|
157
|
+
end
|
151
158
|
rescue
|
152
159
|
puts $!
|
153
160
|
puts $!.backtrace
|
data/lib/plum.rb
CHANGED
@@ -17,7 +17,14 @@ require "plum/frame"
|
|
17
17
|
require "plum/flow_control"
|
18
18
|
require "plum/connection_utils"
|
19
19
|
require "plum/connection"
|
20
|
-
require "plum/https_connection"
|
21
|
-
require "plum/http_connection"
|
22
20
|
require "plum/stream_utils"
|
23
21
|
require "plum/stream"
|
22
|
+
require "plum/server/connection"
|
23
|
+
require "plum/server/https_connection"
|
24
|
+
require "plum/server/http_connection"
|
25
|
+
require "plum/client"
|
26
|
+
require "plum/client/response"
|
27
|
+
require "plum/client/connection"
|
28
|
+
require "plum/client/client_session"
|
29
|
+
require "plum/client/legacy_client_session"
|
30
|
+
require "plum/client/upgrade_client_session"
|
data/lib/plum/client.rb
ADDED
@@ -0,0 +1,198 @@
|
|
1
|
+
# -*- frozen-string-literal: true -*-
|
2
|
+
module Plum
|
3
|
+
class Client
|
4
|
+
DEFAULT_CONFIG = {
|
5
|
+
http2: true,
|
6
|
+
scheme: "https",
|
7
|
+
hostname: nil,
|
8
|
+
verify_mode: OpenSSL::SSL::VERIFY_PEER,
|
9
|
+
ssl_context: nil,
|
10
|
+
http2_settings: {},
|
11
|
+
user_agent: "plum/#{Plum::VERSION}",
|
12
|
+
}.freeze
|
13
|
+
|
14
|
+
attr_reader :host, :port, :config
|
15
|
+
attr_reader :socket, :session
|
16
|
+
|
17
|
+
# Creates a new HTTP client and starts communication.
|
18
|
+
# A shorthand for `Plum::Client.new(args).start(&block)`
|
19
|
+
def self.start(host, port = nil, config = {}, &block)
|
20
|
+
client = self.new(host, port, config)
|
21
|
+
client.start(&block)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Creates a new HTTP client.
|
25
|
+
# @param host [String | IO] the host to connect, or IO object.
|
26
|
+
# @param port [Integer] the port number to connect
|
27
|
+
# @param config [Hash<Symbol, Object>] the client configuration
|
28
|
+
def initialize(host, port = nil, config = {})
|
29
|
+
if host.is_a?(IO)
|
30
|
+
@socket = host
|
31
|
+
else
|
32
|
+
@host = host
|
33
|
+
@port = port || (config[:scheme] == "https" ? 443 : 80)
|
34
|
+
end
|
35
|
+
@config = DEFAULT_CONFIG.merge(hostname: host).merge(config)
|
36
|
+
@started = false
|
37
|
+
end
|
38
|
+
|
39
|
+
# Starts communication.
|
40
|
+
# If block passed, waits for asynchronous requests and closes the connection after calling the block.
|
41
|
+
def start(&block)
|
42
|
+
raise IOError, "Session already started" if @started
|
43
|
+
_start
|
44
|
+
if block_given?
|
45
|
+
begin
|
46
|
+
ret = yield(self)
|
47
|
+
resume
|
48
|
+
return ret
|
49
|
+
ensure
|
50
|
+
close
|
51
|
+
end
|
52
|
+
end
|
53
|
+
self
|
54
|
+
end
|
55
|
+
|
56
|
+
# Resume communication with the server, until the specified (or all running) requests are complete.
|
57
|
+
# @param response [Response] if specified, waits only for the response
|
58
|
+
# @return [Response] if parameter response is specified
|
59
|
+
def resume(response = nil)
|
60
|
+
if response
|
61
|
+
@session.succ until response.failed? || response.finished?
|
62
|
+
response
|
63
|
+
else
|
64
|
+
@session.succ until @session.empty?
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Closes the connection immediately.
|
69
|
+
def close
|
70
|
+
@session.close if @session
|
71
|
+
ensure
|
72
|
+
@socket.close if @socket
|
73
|
+
end
|
74
|
+
|
75
|
+
# Creates a new HTTP request.
|
76
|
+
# @param headers [Hash<String, String>] the request headers
|
77
|
+
# @param body [String] the request body
|
78
|
+
# @param options [Hash<Symbol, Object>] request options
|
79
|
+
# @param block [Proc] if passed, it will be called when received response headers.
|
80
|
+
def request(headers, body, options = {}, &block)
|
81
|
+
raise ArgumentError, ":method and :path headers are required" unless headers[":method"] && headers[":path"]
|
82
|
+
@session.request(headers, body, options, &block)
|
83
|
+
end
|
84
|
+
|
85
|
+
# @!method get!
|
86
|
+
# @!method head!
|
87
|
+
# @!method delete!
|
88
|
+
# @param path [String] the absolute path to request (translated into :path header)
|
89
|
+
# @param options [Hash<Symbol, Object>] the request options
|
90
|
+
# @param block [Proc] if specified, calls the block when finished
|
91
|
+
# Shorthand method for `Client#resume(Client#request(*args))`
|
92
|
+
|
93
|
+
# @!method get
|
94
|
+
# @!method head
|
95
|
+
# @!method delete
|
96
|
+
# @param path [String] the absolute path to request (translated into :path header)
|
97
|
+
# @param options [Hash<Symbol, Object>] the request options
|
98
|
+
# @param block [Proc] if specified, calls the block when finished
|
99
|
+
# Shorthand method for `#request`
|
100
|
+
%w(GET HEAD DELETE).each { |method|
|
101
|
+
define_method(:"#{method.downcase}!") do |path, options = {}, &block|
|
102
|
+
resume _request_helper(method, path, nil, options, &block)
|
103
|
+
end
|
104
|
+
define_method(:"#{method.downcase}") do |path, options = {}, &block|
|
105
|
+
_request_helper(method, path, nil, options, &block)
|
106
|
+
end
|
107
|
+
}
|
108
|
+
# @!method post!
|
109
|
+
# @!method put!
|
110
|
+
# @param path [String] the absolute path to request (translated into :path header)
|
111
|
+
# @param body [String] the request body
|
112
|
+
# @param options [Hash<Symbol, Object>] the request options
|
113
|
+
# @param block [Proc] if specified, calls the block when finished
|
114
|
+
# Shorthand method for `Client#resume(Client#request(*args))`
|
115
|
+
|
116
|
+
# @!method post
|
117
|
+
# @!method put
|
118
|
+
# @param path [String] the absolute path to request (translated into :path header)
|
119
|
+
# @param body [String] the request body
|
120
|
+
# @param options [Hash<Symbol, Object>] the request options
|
121
|
+
# @param block [Proc] if specified, calls the block when finished
|
122
|
+
# Shorthand method for `#request`
|
123
|
+
%w(POST PUT).each { |method|
|
124
|
+
define_method(:"#{method.downcase}!") do |path, body, options = {}, &block|
|
125
|
+
resume _request_helper(method, path, body, options, &block)
|
126
|
+
end
|
127
|
+
define_method(:"#{method.downcase}") do |path, body, options = {}, &block|
|
128
|
+
_request_helper(method, path, body, options, &block)
|
129
|
+
end
|
130
|
+
}
|
131
|
+
|
132
|
+
private
|
133
|
+
# @return [Boolean] http2 nego?
|
134
|
+
def _connect
|
135
|
+
@socket = TCPSocket.open(@host, @port)
|
136
|
+
|
137
|
+
if @config[:scheme] == "https"
|
138
|
+
ctx = @config[:ssl_context] || new_ssl_ctx
|
139
|
+
@socket = OpenSSL::SSL::SSLSocket.new(@socket, ctx)
|
140
|
+
@socket.hostname = @config[:hostname] if @socket.respond_to?(:hostname=)
|
141
|
+
@socket.sync_close = true
|
142
|
+
@socket.connect
|
143
|
+
@socket.post_connection_check(@config[:hostname]) if ctx.verify_mode != OpenSSL::SSL::VERIFY_NONE
|
144
|
+
|
145
|
+
@socket.respond_to?(:alpn_protocol) && @socket.alpn_protocol == "h2" ||
|
146
|
+
@socket.respond_to?(:npn_protocol) && @socket.npn_protocol == "h2"
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def _start
|
151
|
+
@started = true
|
152
|
+
|
153
|
+
klass = @config[:http2] ? ClientSession : LegacyClientSession
|
154
|
+
nego = @socket || _connect
|
155
|
+
|
156
|
+
if @config[:http2]
|
157
|
+
if @config[:scheme] == "https"
|
158
|
+
klass = nego ? ClientSession : LegacyClientSession
|
159
|
+
else
|
160
|
+
klass = UpgradeClientSession
|
161
|
+
end
|
162
|
+
else
|
163
|
+
klass = LegacyClientSession
|
164
|
+
end
|
165
|
+
|
166
|
+
@session = klass.new(@socket, @config)
|
167
|
+
end
|
168
|
+
|
169
|
+
def new_ssl_ctx
|
170
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
171
|
+
ctx.ssl_version = :TLSv1_2
|
172
|
+
ctx.verify_mode = @config[:verify_mode]
|
173
|
+
cert_store = OpenSSL::X509::Store.new
|
174
|
+
cert_store.set_default_paths
|
175
|
+
ctx.cert_store = cert_store
|
176
|
+
if @config[:http2]
|
177
|
+
ctx.ciphers = "ALL:!" + HTTPSServerConnection::CIPHER_BLACKLIST.join(":!")
|
178
|
+
if ctx.respond_to?(:alpn_protocols)
|
179
|
+
ctx.alpn_protocols = ["h2", "http/1.1"]
|
180
|
+
end
|
181
|
+
if ctx.respond_to?(:npn_select_cb) # TODO: RFC 7540 does not define protocol negotiation with NPN
|
182
|
+
ctx.npn_select_cb = -> protocols {
|
183
|
+
protocols.include?("h2") ? "h2" : protocols.first
|
184
|
+
}
|
185
|
+
end
|
186
|
+
end
|
187
|
+
ctx
|
188
|
+
end
|
189
|
+
|
190
|
+
def _request_helper(method, path, body, options, &block)
|
191
|
+
base = { ":method" => method,
|
192
|
+
":path" => path,
|
193
|
+
"user-agent" => @config[:user_agent] }
|
194
|
+
base.merge!(options[:headers]) if options[:headers]
|
195
|
+
request(base, body, options, &block)
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|