async-http 0.6.0 → 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.travis.yml +20 -4
- data/Gemfile +6 -0
- data/README.md +3 -5
- data/Rakefile +16 -17
- data/async-http.gemspec +5 -2
- data/examples/spider.rb +129 -0
- data/lib/async/http/client.rb +32 -28
- data/lib/async/http/pool.rb +120 -0
- data/lib/async/http/protocol.rb +2 -1
- data/lib/async/http/protocol/{http1x.rb → http1.rb} +13 -11
- data/lib/async/http/protocol/http10.rb +7 -1
- data/lib/async/http/protocol/http11.rb +23 -2
- data/lib/async/http/protocol/http2.rb +205 -0
- data/lib/async/http/protocol/https.rb +67 -0
- data/lib/async/http/server.rb +10 -15
- data/lib/async/http/url_endpoint.rb +32 -5
- data/lib/async/http/version.rb +1 -1
- metadata +40 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 5159abaa1252d9eb0885989ab212b178e8bc8c672904b570bd26df5d5b077245
|
4
|
+
data.tar.gz: 6713e0b620617916076956129bed3c3de4338b83dba8bfbffe9f8306c90f564a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2e814e75bddb4cd9fee74c78d45a2da90ab01e7f37454871b5b1ec7d5f42a88fea57b02b6f28dcbdda079803511f2da4b4d9ed2fbf9ecd18b6d0cae1213b07eb
|
7
|
+
data.tar.gz: b08a59a9a07811a5cf91cee4a4bc650eb3bbb753c1076f27fd41c5a237d4684542677b8ed1ce32f2474fe3347f80d05730b8141f1fcd1c8e6c571ac33653b95b
|
data/.travis.yml
CHANGED
@@ -1,20 +1,36 @@
|
|
1
1
|
language: ruby
|
2
|
-
sudo:
|
2
|
+
sudo: required
|
3
3
|
dist: trusty
|
4
4
|
cache: bundler
|
5
5
|
|
6
|
+
addons:
|
7
|
+
apt:
|
8
|
+
packages:
|
9
|
+
# - wrk installed below from xenial
|
10
|
+
- apache2-utils
|
11
|
+
|
12
|
+
before_install:
|
13
|
+
- echo "deb http://us.archive.ubuntu.com/ubuntu/ xenial main restricted universe" | sudo tee -a /etc/apt/sources.list
|
14
|
+
- sudo apt-get update -qq
|
15
|
+
- sudo apt-get install openssl libssl-dev wrk
|
16
|
+
# Rebuild Ruby using the updated OpenSSL libraries which allows 2.5 to pass.
|
17
|
+
# - rvm reinstall --disable-binary `rvm current`
|
18
|
+
# Fix a bug with Ruby 2.5: LoadError: cannot load such file -- bundler/dep_proxy
|
19
|
+
- gem update --system
|
20
|
+
# This works for Ruby 2.3 and 2.4 but not 2.5 which doesn't seem to use the local gem.
|
21
|
+
- gem install openssl
|
22
|
+
|
6
23
|
matrix:
|
7
24
|
include:
|
8
|
-
- rvm: 2.0
|
9
|
-
- rvm: 2.1
|
10
|
-
- rvm: 2.2
|
11
25
|
- rvm: 2.3
|
12
26
|
- rvm: 2.4
|
27
|
+
- rvm: 2.5
|
13
28
|
- rvm: jruby-head
|
14
29
|
env: JRUBY_OPTS="--debug -X+O"
|
15
30
|
- rvm: ruby-head
|
16
31
|
- rvm: rbx-3
|
17
32
|
allow_failures:
|
33
|
+
- rvm: 2.5
|
18
34
|
- rvm: ruby-head
|
19
35
|
- rvm: jruby-head
|
20
36
|
- rvm: rbx-3
|
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -36,9 +36,7 @@ require 'async/http/server'
|
|
36
36
|
require 'async/http/client'
|
37
37
|
require 'async/reactor'
|
38
38
|
|
39
|
-
|
40
|
-
Async::IO::Endpoint.tcp('127.0.0.1', 9294, reuse_port: true)
|
41
|
-
]
|
39
|
+
endpoint = Async::IO::Endpoint.tcp('127.0.0.1', 9294, reuse_port: true)
|
42
40
|
|
43
41
|
class Server < Async::HTTP::Server
|
44
42
|
def handle_request(request, peer, address)
|
@@ -46,8 +44,8 @@ class Server < Async::HTTP::Server
|
|
46
44
|
end
|
47
45
|
end
|
48
46
|
|
49
|
-
server = Server.new(
|
50
|
-
client = Async::HTTP::Client.new(
|
47
|
+
server = Server.new(endpoint)
|
48
|
+
client = Async::HTTP::Client.new(endpoint)
|
51
49
|
|
52
50
|
Async::Reactor.run do |task|
|
53
51
|
server_task = task.async do
|
data/Rakefile
CHANGED
@@ -5,18 +5,21 @@ RSpec::Core::RakeTask.new(:test)
|
|
5
5
|
|
6
6
|
task :default => :test
|
7
7
|
|
8
|
+
require 'async/http/protocol'
|
9
|
+
PROTOCOL = Async::HTTP::Protocol::HTTP2
|
10
|
+
|
11
|
+
task :debug do
|
12
|
+
require 'async/logger'
|
13
|
+
|
14
|
+
Async.logger.level = Logger::DEBUG
|
15
|
+
end
|
16
|
+
|
8
17
|
task :server do
|
9
18
|
require 'async/reactor'
|
10
19
|
require 'async/http/server'
|
11
20
|
|
12
|
-
|
13
|
-
|
14
|
-
end
|
15
|
-
|
16
|
-
server = Async::HTTP::Server.new([
|
17
|
-
Async::IO::Endpoint.tcp('127.0.0.1', 9294, reuse_port: true)
|
18
|
-
], app)
|
19
|
-
|
21
|
+
server = Async::HTTP::Server.new(Async::IO::Endpoint.tcp('0.0.0.0', 9294, reuse_port: true), PROTOCOL)
|
22
|
+
|
20
23
|
Async::Reactor.run do
|
21
24
|
server.run
|
22
25
|
end
|
@@ -26,9 +29,7 @@ task :client do
|
|
26
29
|
require 'async/reactor'
|
27
30
|
require 'async/http/client'
|
28
31
|
|
29
|
-
client = Async::HTTP::Client.new(
|
30
|
-
Async::IO::Endpoint.tcp('127.0.0.1', 9294, reuse_port: true)
|
31
|
-
])
|
32
|
+
client = Async::HTTP::Client.new(Async::IO::Endpoint.tcp('127.0.0.1', 9294, reuse_port: true), PROTOCOL)
|
32
33
|
|
33
34
|
Async::Reactor.run do
|
34
35
|
response = client.get("/")
|
@@ -44,13 +45,11 @@ task :wrk do
|
|
44
45
|
app = lambda do |env|
|
45
46
|
[200, {}, ["Hello World"]]
|
46
47
|
end
|
47
|
-
|
48
|
-
server = Async::HTTP::Server.new(
|
49
|
-
|
50
|
-
], app)
|
51
|
-
|
48
|
+
|
49
|
+
server = Async::HTTP::Server.new(Async::IO::Endpoint.tcp('127.0.0.1', 9294, reuse_port: true), app)
|
50
|
+
|
52
51
|
process_count = Etc.nprocessors
|
53
|
-
|
52
|
+
|
54
53
|
pids = process_count.times.collect do
|
55
54
|
fork do
|
56
55
|
Async::Reactor.run do
|
data/async-http.gemspec
CHANGED
@@ -16,8 +16,11 @@ Gem::Specification.new do |spec|
|
|
16
16
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
17
17
|
spec.require_paths = ["lib"]
|
18
18
|
|
19
|
-
spec.add_dependency("async", "~> 1.
|
20
|
-
spec.add_dependency("async-io", "~> 1.
|
19
|
+
spec.add_dependency("async", "~> 1.4")
|
20
|
+
spec.add_dependency("async-io", "~> 1.5")
|
21
|
+
|
22
|
+
spec.add_dependency("http-2", "~> 0.8")
|
23
|
+
spec.add_dependency("openssl")
|
21
24
|
|
22
25
|
spec.add_development_dependency "async-rspec", "~> 1.1"
|
23
26
|
|
data/examples/spider.rb
ADDED
@@ -0,0 +1,129 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'async/await'
|
4
|
+
|
5
|
+
require 'pry'
|
6
|
+
|
7
|
+
require_relative '../lib/async/http/client'
|
8
|
+
require '../lib/async/http/url_endpoint'
|
9
|
+
require '../lib/async/http/protocol/https'
|
10
|
+
|
11
|
+
require 'trenni/sanitize'
|
12
|
+
require 'set'
|
13
|
+
|
14
|
+
Async.logger.level = Logger::DEBUG
|
15
|
+
|
16
|
+
class HTML < Trenni::Sanitize::Filter
|
17
|
+
def initialize(*)
|
18
|
+
super
|
19
|
+
|
20
|
+
@base = nil
|
21
|
+
@links = []
|
22
|
+
end
|
23
|
+
|
24
|
+
attr :base
|
25
|
+
attr :links
|
26
|
+
|
27
|
+
def filter(node)
|
28
|
+
if node.name == 'base'
|
29
|
+
@base = node['href']
|
30
|
+
elsif node.name == 'a'
|
31
|
+
@links << node['href']
|
32
|
+
end
|
33
|
+
|
34
|
+
node.skip!(TAG)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
class Cache
|
39
|
+
def initialize
|
40
|
+
@clients = {}
|
41
|
+
end
|
42
|
+
|
43
|
+
def close
|
44
|
+
@clients.each(&:close)
|
45
|
+
@clients.clear
|
46
|
+
end
|
47
|
+
|
48
|
+
def [] endpoint
|
49
|
+
url = endpoint.specification
|
50
|
+
key = "#{url.scheme}://#{url.userinfo}@#{url.hostname}"
|
51
|
+
|
52
|
+
@clients[key] ||= Async::HTTP::Client.new(endpoint, endpoint.secure? ? Async::HTTP::Protocol::HTTPS : Async::HTTP::Protocol::HTTP1)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
class << self
|
57
|
+
include Async::Await
|
58
|
+
|
59
|
+
async def fetch(url, depth = 4, fetched = Set.new, clients = Cache.new)
|
60
|
+
return if fetched.include?(url) or depth == 0 or url.host != "www.codeotaku.com"
|
61
|
+
fetched << url
|
62
|
+
|
63
|
+
endpoint = Async::HTTP::URLEndpoint.new(url)
|
64
|
+
client = clients[endpoint]
|
65
|
+
|
66
|
+
request_uri = endpoint.specification.request_uri
|
67
|
+
puts "GET #{url} (depth = #{depth})"
|
68
|
+
|
69
|
+
response = timeout(10) do
|
70
|
+
client.get(request_uri, {
|
71
|
+
':authority' => endpoint.specification.hostname,
|
72
|
+
'accept' => '*/*',
|
73
|
+
'user-agent' => 'spider',
|
74
|
+
})
|
75
|
+
end
|
76
|
+
|
77
|
+
if response.status >= 300 && response.status < 400
|
78
|
+
location = url + response.headers['location']
|
79
|
+
# puts "Following redirect to #{location}"
|
80
|
+
return fetch(location, depth-1, fetched)
|
81
|
+
end
|
82
|
+
|
83
|
+
content_type = response.headers['content-type']
|
84
|
+
unless content_type&.start_with? 'text/html'
|
85
|
+
# puts "Unsupported content type: #{response.headers['content-type']}"
|
86
|
+
return
|
87
|
+
end
|
88
|
+
|
89
|
+
base = endpoint.specification
|
90
|
+
|
91
|
+
begin
|
92
|
+
html = HTML.parse(response.body)
|
93
|
+
rescue
|
94
|
+
# Async.logger.error($!)
|
95
|
+
return
|
96
|
+
end
|
97
|
+
|
98
|
+
if html.base
|
99
|
+
base = base + html.base
|
100
|
+
end
|
101
|
+
|
102
|
+
html.links.each do |href|
|
103
|
+
begin
|
104
|
+
full_url = base + href
|
105
|
+
|
106
|
+
fetch(full_url, depth - 1, fetched) if full_url.kind_of? URI::HTTP
|
107
|
+
rescue ArgumentError, URI::InvalidURIError
|
108
|
+
# puts "Could not fetch #{href}, relative to #{base}."
|
109
|
+
end
|
110
|
+
end
|
111
|
+
rescue Async::TimeoutError
|
112
|
+
Async.logger.error("Timeout while fetching #{url}")
|
113
|
+
rescue StandardError
|
114
|
+
Async.logger.error($!)
|
115
|
+
ensure
|
116
|
+
puts "Closing client from spider..."
|
117
|
+
client.close if client
|
118
|
+
end
|
119
|
+
|
120
|
+
async def fetch_one(url)
|
121
|
+
endpoint = Async::HTTP::URLEndpoint.new(url)
|
122
|
+
client = Async::HTTP::Client.new(endpoint, endpoint.secure? ? Async::HTTP::Protocol::HTTPS : Async::HTTP::Protocol::HTTP1)
|
123
|
+
|
124
|
+
binding.pry
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
fetch_one(URI.parse("https://www.codeotaku.com"))
|
129
|
+
#puts "Finished."
|
data/lib/async/http/client.rb
CHANGED
@@ -25,52 +25,56 @@ require_relative 'protocol'
|
|
25
25
|
module Async
|
26
26
|
module HTTP
|
27
27
|
class Client
|
28
|
-
def initialize(
|
29
|
-
@
|
28
|
+
def initialize(endpoint, protocol = Protocol::HTTPS, **options)
|
29
|
+
@endpoint = endpoint
|
30
30
|
|
31
|
-
@
|
31
|
+
@protocol = protocol
|
32
|
+
|
33
|
+
@connections = connect(**options)
|
32
34
|
end
|
33
35
|
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
36
|
+
def self.open(*args, &block)
|
37
|
+
client = self.new(*args)
|
38
|
+
|
39
|
+
return client unless block_given?
|
40
|
+
|
41
|
+
begin
|
42
|
+
yield client
|
43
|
+
ensure
|
44
|
+
client.close
|
41
45
|
end
|
42
46
|
end
|
43
47
|
|
44
|
-
def
|
45
|
-
|
46
|
-
protocol.send_request(HEAD, path, *args)
|
47
|
-
end
|
48
|
+
def close
|
49
|
+
@connections.close
|
48
50
|
end
|
49
51
|
|
50
|
-
|
51
|
-
|
52
|
-
|
52
|
+
VERBS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']
|
53
|
+
|
54
|
+
VERBS.each do |verb|
|
55
|
+
define_method(verb.downcase) do |*args|
|
56
|
+
self.request(verb, *args)
|
53
57
|
end
|
54
58
|
end
|
55
59
|
|
56
|
-
def
|
57
|
-
|
58
|
-
|
60
|
+
def request(*args)
|
61
|
+
@connections.acquire do |connection|
|
62
|
+
connection.send_request(*args)
|
59
63
|
end
|
60
64
|
end
|
61
65
|
|
62
|
-
|
66
|
+
protected
|
63
67
|
|
64
|
-
def connect
|
65
|
-
|
66
|
-
|
68
|
+
def connect(connection_limit: nil)
|
69
|
+
Pool.new(connection_limit) do
|
70
|
+
Async.logger.debug(self) {"Making connection to #{@endpoint.inspect}"}
|
67
71
|
|
68
|
-
endpoint.
|
69
|
-
|
72
|
+
@endpoint.each do |endpoint|
|
73
|
+
peer = endpoint.connect
|
70
74
|
|
71
|
-
|
75
|
+
stream = IO::Stream.new(peer)
|
72
76
|
|
73
|
-
|
77
|
+
break @protocol.client(stream)
|
74
78
|
end
|
75
79
|
end
|
76
80
|
end
|
@@ -0,0 +1,120 @@
|
|
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
|
+
module Async
|
22
|
+
module HTTP
|
23
|
+
# Pool behaviours
|
24
|
+
#
|
25
|
+
# - Single request per connection (HTTP/1 without keep-alive)
|
26
|
+
# - Multiple sequential requests per connection (HTTP1 with keep-alive)
|
27
|
+
# - Multiplex requests per connection (HTTP2)
|
28
|
+
#
|
29
|
+
# In general we don't know the policy until connection is established.
|
30
|
+
#
|
31
|
+
# This pool doesn't impose a maximum number of open resources, but it WILL block if there are no available resources and trying to allocate another one fails.
|
32
|
+
#
|
33
|
+
# Resources must respond to
|
34
|
+
# #multiplex -> 1 or more.
|
35
|
+
# #reusable? -> can be used again.
|
36
|
+
#
|
37
|
+
class Pool
|
38
|
+
def initialize(limit = nil, &block)
|
39
|
+
@available = {} # resource => count
|
40
|
+
@waiting = []
|
41
|
+
|
42
|
+
@limit = limit
|
43
|
+
|
44
|
+
@constructor = block
|
45
|
+
end
|
46
|
+
|
47
|
+
def acquire
|
48
|
+
resource = wait_for_next_available
|
49
|
+
|
50
|
+
return resource unless block_given?
|
51
|
+
|
52
|
+
begin
|
53
|
+
yield resource
|
54
|
+
ensure
|
55
|
+
release(resource)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Make the resource available and let waiting tasks know that there is something available.
|
60
|
+
def release(resource)
|
61
|
+
if resource.reusable?
|
62
|
+
Async.logger.debug(self) {"Reusing resource #{resource}"}
|
63
|
+
|
64
|
+
@available[resource] -= 1
|
65
|
+
|
66
|
+
if task = @waiting.pop
|
67
|
+
task.resume
|
68
|
+
end
|
69
|
+
else
|
70
|
+
Async.logger.debug(self) {"Closing resource: #{resource}"}
|
71
|
+
resource.close
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def close
|
76
|
+
@available.each_key(&:close)
|
77
|
+
@available.clear
|
78
|
+
end
|
79
|
+
|
80
|
+
protected
|
81
|
+
|
82
|
+
def wait_for_next_available
|
83
|
+
until resource = next_available
|
84
|
+
@waiting << Fiber.current
|
85
|
+
Task.yield
|
86
|
+
end
|
87
|
+
|
88
|
+
return resource
|
89
|
+
end
|
90
|
+
|
91
|
+
def create_resource
|
92
|
+
begin
|
93
|
+
# This might fail, which is okay :)
|
94
|
+
resource = @constructor.call
|
95
|
+
rescue StandardError
|
96
|
+
Async.logger.error "#{$!}: #{$!.backtrace}"
|
97
|
+
return nil
|
98
|
+
end
|
99
|
+
|
100
|
+
@available[resource] = 1
|
101
|
+
|
102
|
+
return resource
|
103
|
+
end
|
104
|
+
|
105
|
+
# TODO this does not take into account resources that start off good but can fail.
|
106
|
+
def next_available
|
107
|
+
@available.each do |resource, count|
|
108
|
+
if count < resource.multiplex
|
109
|
+
@available[resource] += 1
|
110
|
+
|
111
|
+
return resource
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
Async.logger.debug(self) {"No available resources, allocating new one..."}
|
116
|
+
return create_resource
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
data/lib/async/http/protocol.rb
CHANGED
@@ -21,27 +21,33 @@
|
|
21
21
|
require_relative 'http10'
|
22
22
|
require_relative 'http11'
|
23
23
|
|
24
|
+
require_relative '../pool'
|
25
|
+
|
24
26
|
module Async
|
25
27
|
module HTTP
|
26
28
|
module Protocol
|
27
29
|
# A server that supports both HTTP1.0 and HTTP1.1 semantics by detecting the version of the request.
|
28
|
-
class
|
30
|
+
class HTTP1 < Async::IO::Protocol::Line
|
29
31
|
HANDLERS = {
|
30
32
|
"HTTP/1.0" => HTTP10,
|
31
33
|
"HTTP/1.1" => HTTP11,
|
32
34
|
}
|
33
35
|
|
34
|
-
def initialize(stream
|
36
|
+
def initialize(stream)
|
35
37
|
super(stream, HTTP11::CRLF)
|
38
|
+
end
|
39
|
+
|
40
|
+
class << self
|
41
|
+
def client(*args)
|
42
|
+
HTTP11.new(*args)
|
43
|
+
end
|
36
44
|
|
37
|
-
|
38
|
-
|
39
|
-
@handler = nil
|
45
|
+
alias server new
|
40
46
|
end
|
41
47
|
|
42
48
|
def create_handler(version)
|
43
|
-
if klass =
|
44
|
-
klass.
|
49
|
+
if klass = HANDLERS[version]
|
50
|
+
klass.server(@stream)
|
45
51
|
else
|
46
52
|
raise RuntimeError, "Unsupported protocol version #{version}"
|
47
53
|
end
|
@@ -52,10 +58,6 @@ module Async
|
|
52
58
|
|
53
59
|
create_handler(version).receive_requests(&block)
|
54
60
|
end
|
55
|
-
|
56
|
-
def send_request(request, &block)
|
57
|
-
create_handler(request.version).send_request(request, &block)
|
58
|
-
end
|
59
61
|
end
|
60
62
|
end
|
61
63
|
end
|
@@ -45,8 +45,14 @@ module Async
|
|
45
45
|
|
46
46
|
write_response(request.version, status, headers, body)
|
47
47
|
|
48
|
-
|
48
|
+
unless keep_alive?(request.headers) && keep_alive?(headers)
|
49
|
+
@keep_alive = false
|
50
|
+
|
51
|
+
break
|
52
|
+
end
|
49
53
|
end
|
54
|
+
|
55
|
+
return false
|
50
56
|
end
|
51
57
|
|
52
58
|
def write_body(body, chunked = true)
|
@@ -35,6 +35,22 @@ module Async
|
|
35
35
|
|
36
36
|
def initialize(stream)
|
37
37
|
super(stream, CRLF)
|
38
|
+
|
39
|
+
@keep_alive = true
|
40
|
+
end
|
41
|
+
|
42
|
+
# Only one simultaneous connection at a time.
|
43
|
+
def multiplex
|
44
|
+
1
|
45
|
+
end
|
46
|
+
|
47
|
+
def reusable?
|
48
|
+
@keep_alive
|
49
|
+
end
|
50
|
+
|
51
|
+
class << self
|
52
|
+
alias server new
|
53
|
+
alias client new
|
38
54
|
end
|
39
55
|
|
40
56
|
HTTP_CONNECTION = 'HTTP_CONNECTION'.freeze
|
@@ -60,7 +76,11 @@ module Async
|
|
60
76
|
|
61
77
|
write_response(request.version, status, headers, body)
|
62
78
|
|
63
|
-
|
79
|
+
unless keep_alive?(request.headers) and keep_alive?(headers)
|
80
|
+
@keep_alive = false
|
81
|
+
|
82
|
+
break
|
83
|
+
end
|
64
84
|
|
65
85
|
# This ensures we yield at least once every iteration of the loop and allow other fibers to execute.
|
66
86
|
task.yield
|
@@ -72,7 +92,6 @@ module Async
|
|
72
92
|
write_request(method, path, version, headers, body)
|
73
93
|
|
74
94
|
return Response.new(*read_response)
|
75
|
-
|
76
95
|
rescue EOFError
|
77
96
|
return nil
|
78
97
|
end
|
@@ -92,6 +111,8 @@ module Async
|
|
92
111
|
headers = read_headers
|
93
112
|
body = read_body(headers)
|
94
113
|
|
114
|
+
@keep_alive = keep_alive?(headers)
|
115
|
+
|
95
116
|
return version, Integer(status), reason, headers, body
|
96
117
|
end
|
97
118
|
|
@@ -0,0 +1,205 @@
|
|
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 'request'
|
22
|
+
require_relative 'response'
|
23
|
+
|
24
|
+
require 'async/notification'
|
25
|
+
|
26
|
+
require 'http/2'
|
27
|
+
|
28
|
+
module Async
|
29
|
+
module HTTP
|
30
|
+
module Protocol
|
31
|
+
# A server that supports both HTTP1.0 and HTTP1.1 semantics by detecting the version of the request.
|
32
|
+
class HTTP2
|
33
|
+
def self.client(stream)
|
34
|
+
self.new(::HTTP2::Client.new, stream)
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.server(stream)
|
38
|
+
self.new(::HTTP2::Server.new, stream)
|
39
|
+
end
|
40
|
+
|
41
|
+
def initialize(controller, stream)
|
42
|
+
@controller = controller
|
43
|
+
@stream = stream
|
44
|
+
|
45
|
+
@controller.on(:frame) do |data|
|
46
|
+
@stream.write(data)
|
47
|
+
@stream.flush
|
48
|
+
end
|
49
|
+
|
50
|
+
# @controller.on(:frame_sent) do |frame|
|
51
|
+
# Async.logger.debug(self) {"Sent frame: #{frame.inspect}"}
|
52
|
+
# end
|
53
|
+
#
|
54
|
+
# @controller.on(:frame_received) do |frame|
|
55
|
+
# Async.logger.debug(self) {"Received frame: #{frame.inspect}"}
|
56
|
+
# end
|
57
|
+
|
58
|
+
if @controller.is_a? ::HTTP2::Client
|
59
|
+
@controller.send_connection_preface
|
60
|
+
@reader = read_in_background
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Multiple requests can be processed at the same time.
|
65
|
+
def multiplex
|
66
|
+
@controller.remote_settings[:settings_max_concurrent_streams]
|
67
|
+
end
|
68
|
+
|
69
|
+
def reusable?
|
70
|
+
@reader.alive?
|
71
|
+
end
|
72
|
+
|
73
|
+
def read_in_background(task: Task.current)
|
74
|
+
task.async do |nested_task|
|
75
|
+
while true
|
76
|
+
if data = @stream.io.read(10)
|
77
|
+
# Async.logger.debug(self) {"Reading data: #{data.size} bytes"}
|
78
|
+
@controller << data
|
79
|
+
else
|
80
|
+
Async.logger.debug(self) {"Connection reset by peer!"}
|
81
|
+
break
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def close
|
88
|
+
Async.logger.debug(self) {"Closing connection"}
|
89
|
+
@reader.stop
|
90
|
+
@stream.close
|
91
|
+
end
|
92
|
+
|
93
|
+
def receive_requests(&block)
|
94
|
+
# emits new streams opened by the client
|
95
|
+
@controller.on(:stream) do |stream|
|
96
|
+
request = Request.new
|
97
|
+
request.version = "HTTP/2.0"
|
98
|
+
request.headers = {}
|
99
|
+
|
100
|
+
# stream.on(:active) { } # fires when stream transitions to open state
|
101
|
+
# stream.on(:close) { } # stream is closed by client and server
|
102
|
+
|
103
|
+
stream.on(:headers) do |headers|
|
104
|
+
headers.each do |key, value|
|
105
|
+
if key == ':method'
|
106
|
+
request.method = value
|
107
|
+
elsif key == ':path'
|
108
|
+
request.path = value
|
109
|
+
else
|
110
|
+
request.headers[key] = value
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
stream.on(:data) do |body|
|
116
|
+
request.body = body
|
117
|
+
end
|
118
|
+
|
119
|
+
stream.on(:half_close) do
|
120
|
+
response = yield request
|
121
|
+
|
122
|
+
# send response
|
123
|
+
stream.headers(':status' => response[0].to_s)
|
124
|
+
|
125
|
+
stream.headers(response[1]) unless response[1].empty?
|
126
|
+
|
127
|
+
response[2].each do |chunk|
|
128
|
+
stream.data(chunk, end_stream: false)
|
129
|
+
end
|
130
|
+
|
131
|
+
stream.data("", end_stream: true)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
while data = @stream.io.read(1024)
|
136
|
+
@controller << data
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def send_request(method, path, headers = {}, body = nil)
|
141
|
+
stream = @controller.new_stream
|
142
|
+
|
143
|
+
internal_headers = {
|
144
|
+
':scheme' => 'https',
|
145
|
+
':method' => method,
|
146
|
+
':path' => path,
|
147
|
+
}.merge(headers)
|
148
|
+
|
149
|
+
stream.headers(internal_headers, end_stream: true)
|
150
|
+
|
151
|
+
# if body
|
152
|
+
# body.each do |chunk|
|
153
|
+
# stream.data(chunk, end_stream: false)
|
154
|
+
# end
|
155
|
+
#
|
156
|
+
# stream.data("", end_stream: true)
|
157
|
+
# end
|
158
|
+
|
159
|
+
response = Response.new
|
160
|
+
response.version = "HTTP/2"
|
161
|
+
response.headers = {}
|
162
|
+
response.body = Async::IO::BinaryString.new
|
163
|
+
|
164
|
+
stream.on(:headers) do |headers|
|
165
|
+
# Async.logger.debug(self) {"Stream headers: #{headers.inspect}"}
|
166
|
+
|
167
|
+
headers.each do |key, value|
|
168
|
+
if key == ':status'
|
169
|
+
response.status = value.to_i
|
170
|
+
elsif key == ':reason'
|
171
|
+
response.reason = value
|
172
|
+
else
|
173
|
+
response.headers[key] = value
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
stream.on(:data) do |body|
|
179
|
+
# Async.logger.debug(self) {"Stream data: #{body.size} bytes"}
|
180
|
+
response.body << body
|
181
|
+
end
|
182
|
+
|
183
|
+
finished = Async::Notification.new
|
184
|
+
|
185
|
+
stream.on(:half_close) do
|
186
|
+
# Async.logger.debug(self) {"Stream half-closed."}
|
187
|
+
end
|
188
|
+
|
189
|
+
stream.on(:close) do
|
190
|
+
# Async.logger.debug(self) {"Stream closed, sending signal."}
|
191
|
+
finished.signal
|
192
|
+
end
|
193
|
+
|
194
|
+
@stream.flush
|
195
|
+
|
196
|
+
# Async.logger.debug(self) {"Stream flushed, waiting for signal."}
|
197
|
+
finished.wait
|
198
|
+
|
199
|
+
# Async.logger.debug(self) {"Stream finished: #{response.inspect}"}
|
200
|
+
return response
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
@@ -0,0 +1,67 @@
|
|
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 'http1'
|
22
|
+
require_relative 'http2'
|
23
|
+
|
24
|
+
require_relative '../pool'
|
25
|
+
|
26
|
+
require 'openssl'
|
27
|
+
|
28
|
+
unless OpenSSL::SSL::SSLContext.instance_methods.include? :alpn_protocols=
|
29
|
+
abort "OpenSSL implementation doesn't support ALPN."
|
30
|
+
end
|
31
|
+
|
32
|
+
module Async
|
33
|
+
module HTTP
|
34
|
+
module Protocol
|
35
|
+
# A server that supports both HTTP1.0 and HTTP1.1 semantics by detecting the version of the request.
|
36
|
+
module HTTPS
|
37
|
+
HANDLERS = {
|
38
|
+
"http/1.0" => HTTP10,
|
39
|
+
"http/1.1" => HTTP11,
|
40
|
+
"h2" => HTTP2,
|
41
|
+
nil => HTTP11,
|
42
|
+
}
|
43
|
+
|
44
|
+
def self.protocol_for(stream)
|
45
|
+
# alpn_protocol is only available if openssl v1.0.2+
|
46
|
+
name = stream.io.alpn_protocol
|
47
|
+
|
48
|
+
Async.logger.debug(self) {"Negotiating protocol #{name.inspect}..."}
|
49
|
+
|
50
|
+
if protocol = HANDLERS[name]
|
51
|
+
return protocol
|
52
|
+
else
|
53
|
+
throw ArgumentError.new("Could not determine protocol for connection (#{name.inspect}).")
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.client(stream)
|
58
|
+
protocol_for(stream).client(stream)
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.server(stream)
|
62
|
+
protocol_for(stream).server(stream)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
data/lib/async/http/server.rb
CHANGED
@@ -25,45 +25,40 @@ require_relative 'protocol'
|
|
25
25
|
module Async
|
26
26
|
module HTTP
|
27
27
|
class Server
|
28
|
-
def initialize(
|
29
|
-
@
|
28
|
+
def initialize(endpoint, protocol_class = Protocol::HTTP1)
|
29
|
+
@endpoint = endpoint
|
30
30
|
@protocol_class = protocol_class
|
31
31
|
end
|
32
32
|
|
33
33
|
def handle_request(request, peer, address)
|
34
|
-
[200, {}, []]
|
34
|
+
[200, {"Content-Type" => "text/plain"}, ["Hello World"]]
|
35
35
|
end
|
36
36
|
|
37
37
|
def accept(peer, address)
|
38
38
|
stream = Async::IO::Stream.new(peer)
|
39
|
+
protocol = @protocol_class.server(stream)
|
39
40
|
|
40
|
-
|
41
|
-
|
42
|
-
# puts "Opening session on child pid #{Process.pid}"
|
41
|
+
# Async.logger.debug(self) {"Incoming connnection from #{address.inspect}"}
|
43
42
|
|
44
43
|
hijack = catch(:hijack) do
|
45
44
|
protocol.receive_requests do |request|
|
45
|
+
# Async.logger.debug(self) {"Incoming request from #{address.inspect}: #{request.method} #{request.path}"}
|
46
46
|
handle_request(request, peer, address)
|
47
47
|
end
|
48
48
|
end
|
49
|
-
|
49
|
+
|
50
50
|
if hijack
|
51
51
|
hijack.call
|
52
52
|
end
|
53
|
-
|
54
|
-
# puts "Closing session"
|
55
|
-
|
56
53
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE
|
57
54
|
# Sometimes client will disconnect without completing a result or reading the entire buffer.
|
58
55
|
return nil
|
56
|
+
ensure
|
57
|
+
peer.close
|
59
58
|
end
|
60
59
|
|
61
60
|
def run
|
62
|
-
|
63
|
-
# puts "Binding to #{endpoint} on process #{Process.pid}"
|
64
|
-
|
65
|
-
endpoint.accept(&self.method(:accept))
|
66
|
-
end
|
61
|
+
@endpoint.accept(&self.method(:accept))
|
67
62
|
end
|
68
63
|
end
|
69
64
|
end
|
@@ -24,16 +24,32 @@ require 'async/io/ssl_socket'
|
|
24
24
|
module Async
|
25
25
|
module HTTP
|
26
26
|
class URLEndpoint < Async::IO::Endpoint
|
27
|
+
DEFAULT_ALPH_PROTOCOLS = ['h2', 'http/1.1'].freeze
|
28
|
+
|
27
29
|
def self.parse(string, **options)
|
28
30
|
self.new(URI.parse(string), **options)
|
29
31
|
end
|
30
32
|
|
33
|
+
def initialize(url, endpoint = nil, **options)
|
34
|
+
@url = url
|
35
|
+
@endpoint = endpoint
|
36
|
+
@options = options
|
37
|
+
end
|
38
|
+
|
31
39
|
def address
|
32
40
|
endpoint.address
|
33
41
|
end
|
34
42
|
|
35
43
|
def secure?
|
36
|
-
['https', 'wss'].include?(
|
44
|
+
['https', 'wss'].include?(@url.scheme)
|
45
|
+
end
|
46
|
+
|
47
|
+
def protocol
|
48
|
+
if secure?
|
49
|
+
Protocol::HTTPS
|
50
|
+
else
|
51
|
+
Protocol::HTTP1
|
52
|
+
end
|
37
53
|
end
|
38
54
|
|
39
55
|
def default_port
|
@@ -41,24 +57,27 @@ module Async
|
|
41
57
|
end
|
42
58
|
|
43
59
|
def port
|
44
|
-
|
60
|
+
@url.port || default_port
|
45
61
|
end
|
46
62
|
|
47
63
|
def hostname
|
48
|
-
|
64
|
+
@url.hostname
|
49
65
|
end
|
50
66
|
|
51
67
|
def ssl_context
|
52
|
-
options[:ssl_context] || ::OpenSSL::SSL::SSLContext.new.tap do |context|
|
68
|
+
@options[:ssl_context] || ::OpenSSL::SSL::SSLContext.new.tap do |context|
|
69
|
+
context.alpn_protocols = @options.fetch(:alpn_protocols, DEFAULT_ALPH_PROTOCOLS)
|
53
70
|
context.set_params
|
54
71
|
end
|
55
72
|
end
|
56
73
|
|
57
74
|
def endpoint
|
58
|
-
unless
|
75
|
+
unless @endpoint
|
59
76
|
@endpoint = Async::IO::Endpoint.tcp(hostname, port)
|
60
77
|
|
61
78
|
if secure?
|
79
|
+
Async.logger.debug(self) {"Setting hostname: #{self.hostname}"}
|
80
|
+
|
62
81
|
# Wrap it in SSL:
|
63
82
|
@endpoint = Async::IO::SecureEndpoint.new(@endpoint,
|
64
83
|
ssl_context: ssl_context,
|
@@ -77,6 +96,14 @@ module Async
|
|
77
96
|
def connect(*args, &block)
|
78
97
|
endpoint.connect(*args, &block)
|
79
98
|
end
|
99
|
+
|
100
|
+
def each
|
101
|
+
return to_enum unless block_given?
|
102
|
+
|
103
|
+
self.endpoint.each do |endpoint|
|
104
|
+
yield self.class.new(@url, endpoint, @options)
|
105
|
+
end
|
106
|
+
end
|
80
107
|
end
|
81
108
|
end
|
82
109
|
end
|
data/lib/async/http/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: async-http
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.8.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Samuel Williams
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-
|
11
|
+
date: 2018-03-26 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: async
|
@@ -16,28 +16,56 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - "~>"
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: '1.
|
19
|
+
version: '1.4'
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: '1.
|
26
|
+
version: '1.4'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: async-io
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
31
|
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version: '1.
|
33
|
+
version: '1.5'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.5'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: http-2
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0.8'
|
34
48
|
type: :runtime
|
35
49
|
prerelease: false
|
36
50
|
version_requirements: !ruby/object:Gem::Requirement
|
37
51
|
requirements:
|
38
52
|
- - "~>"
|
39
53
|
- !ruby/object:Gem::Version
|
40
|
-
version: '
|
54
|
+
version: '0.8'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: openssl
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
41
69
|
- !ruby/object:Gem::Dependency
|
42
70
|
name: async-rspec
|
43
71
|
requirement: !ruby/object:Gem::Requirement
|
@@ -109,12 +137,16 @@ files:
|
|
109
137
|
- README.md
|
110
138
|
- Rakefile
|
111
139
|
- async-http.gemspec
|
140
|
+
- examples/spider.rb
|
112
141
|
- lib/async/http.rb
|
113
142
|
- lib/async/http/client.rb
|
143
|
+
- lib/async/http/pool.rb
|
114
144
|
- lib/async/http/protocol.rb
|
145
|
+
- lib/async/http/protocol/http1.rb
|
115
146
|
- lib/async/http/protocol/http10.rb
|
116
147
|
- lib/async/http/protocol/http11.rb
|
117
|
-
- lib/async/http/protocol/
|
148
|
+
- lib/async/http/protocol/http2.rb
|
149
|
+
- lib/async/http/protocol/https.rb
|
118
150
|
- lib/async/http/protocol/request.rb
|
119
151
|
- lib/async/http/protocol/response.rb
|
120
152
|
- lib/async/http/server.rb
|
@@ -139,7 +171,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
139
171
|
version: '0'
|
140
172
|
requirements: []
|
141
173
|
rubyforge_project:
|
142
|
-
rubygems_version: 2.6
|
174
|
+
rubygems_version: 2.7.6
|
143
175
|
signing_key:
|
144
176
|
specification_version: 4
|
145
177
|
summary: ''
|