plum 0.1.3 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Circle CI](https://circleci.com/gh/rhenium/plum.svg?style=svg)](https://circleci.com/gh/rhenium/plum) [![Build Status](https://travis-ci.org/rhenium/plum.png?branch=master)](https://travis-ci.org/rhenium/plum) [![Code Climate](https://codeclimate.com/github/rhenium/plum/badges/gpa.svg)](https://codeclimate.com/github/rhenium/plum) [![Test Coverage](https://codeclimate.com/github/rhenium/plum/badges/coverage.svg)](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
|