├── .ruby-gemset ├── .ruby-version ├── Gemfile ├── .rspec ├── lib ├── net-http2 │ ├── version.rb │ ├── response.rb │ ├── callbacks.rb │ ├── stream.rb │ ├── socket.rb │ ├── request.rb │ └── client.rb ├── net-http2.rb └── http2_patch.rb ├── Rakefile ├── .travis.yml ├── bin ├── setup └── console ├── spec ├── spec_helper.rb ├── support │ ├── api_helpers.rb │ ├── priv │ │ ├── README.md │ │ ├── server.crt │ │ ├── server.key │ │ └── apn.pem │ ├── shared_examples │ │ └── callbacks.rb │ └── dummy_server.rb ├── http2-client │ ├── response_spec.rb │ ├── client_spec.rb │ └── request_spec.rb └── api │ ├── connection_timeouts_spec.rb │ ├── timeouts_with_sync_requests_spec.rb │ ├── ssl_requests_spec.rb │ ├── sending_sync_requests_spec.rb │ ├── sending_async_requests_spec.rb │ └── errors_spec.rb ├── .gitignore ├── LICENSE.md ├── net-http2.gemspec └── README.md /.ruby-gemset: -------------------------------------------------------------------------------- 1 | net-http2 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-2.3.1 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/net-http2/version.rb: -------------------------------------------------------------------------------- 1 | module NetHttp2 2 | VERSION = '0.15.0'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.1 4 | - 2.2 5 | - 2.3.0 6 | - 2.3.1 7 | 8 | branches: 9 | only: 10 | - master 11 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | 3 | require 'net-http2' 4 | require 'rspec' 5 | 6 | Dir[File.expand_path("../support/**/*.rb", __FILE__)].each { |f| require f } 7 | 8 | RSpec.configure do |config| 9 | config.order = :random 10 | config.include NetHttp2::ApiHelpers 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/api_helpers.rb: -------------------------------------------------------------------------------- 1 | module NetHttp2 2 | 3 | module ApiHelpers 4 | WAIT_INTERVAL = 1 5 | 6 | def wait_for(seconds=2, &block) 7 | count = 1 / WAIT_INTERVAL 8 | 9 | (0..(count * seconds)).each do 10 | break if block.call 11 | sleep WAIT_INTERVAL 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/net-http2/response.rb: -------------------------------------------------------------------------------- 1 | module NetHttp2 2 | 3 | class Response 4 | attr_reader :headers, :body 5 | 6 | def initialize(options={}) 7 | @headers = options[:headers] 8 | @body = options[:body] 9 | end 10 | 11 | def status 12 | @headers[':status'] if @headers 13 | end 14 | 15 | def ok? 16 | status == '200' 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "net-http2" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /lib/net-http2.rb: -------------------------------------------------------------------------------- 1 | require 'net-http2/callbacks' 2 | require 'net-http2/client' 3 | require 'net-http2/response' 4 | require 'net-http2/request' 5 | require 'net-http2/socket' 6 | require 'net-http2/stream' 7 | require 'net-http2/version' 8 | 9 | require 'http2_patch' 10 | 11 | module NetHttp2 12 | raise "Cannot require NetHttp2, unsupported engine '#{RUBY_ENGINE}'" unless RUBY_ENGINE == "ruby" 13 | end 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vim 2 | .*.sw[a-z] 3 | *.un~ 4 | Session.vim 5 | 6 | # mine 7 | .idea 8 | 9 | # OSX ignores 10 | .DS_Store 11 | .AppleDouble 12 | .LSOverride 13 | Icon 14 | 15 | ._* 16 | .Spotlight-V100 17 | .Trashes 18 | .AppleDB 19 | .AppleDesktop 20 | Network Trash Folder 21 | Temporary Items 22 | .apdisk 23 | 24 | # gem 25 | /.bundle/ 26 | /.yardoc 27 | /Gemfile.lock 28 | /_yardoc/ 29 | /coverage/ 30 | /doc/ 31 | /pkg/ 32 | /spec/reports/ 33 | /tmp/ 34 | -------------------------------------------------------------------------------- /lib/net-http2/callbacks.rb: -------------------------------------------------------------------------------- 1 | module NetHttp2 2 | 3 | module Callbacks 4 | 5 | def on(event, &block) 6 | raise ArgumentError, 'on event must provide a block' unless block_given? 7 | 8 | @callback_events ||= {} 9 | @callback_events[event] ||= [] 10 | @callback_events[event] << block 11 | end 12 | 13 | def emit(event, arg) 14 | return unless @callback_events && @callback_events[event] 15 | @callback_events[event].each { |b| b.call(arg) } 16 | end 17 | 18 | def callback_events 19 | @callback_events || {} 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/support/priv/README.md: -------------------------------------------------------------------------------- 1 | # Private Key and Cert - Disclaimer 2 | 3 | Please note that the included certificate 'server.crt' and private key 'server.key' are 4 | publicly available via the Apnotic repositories, and should NOT be used for any secure application. 5 | These have been provided here for your testing comfort only. 6 | 7 | You may consider getting your copy of [OpenSSL](http://www.openssl.org) to generate your server's own 8 | certificate and private key by issuing a command similar to: 9 | 10 | ``` 11 | $ openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout server.key -out server.crt 12 | ``` 13 | -------------------------------------------------------------------------------- /spec/support/shared_examples/callbacks.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "a class that implements events subscription & emission" do |options={}| 2 | 3 | describe "Events subscription & emission" do 4 | 5 | [ 6 | :headers, 7 | :body_chunk, 8 | :close 9 | ].each do |event| 10 | it "subscribes and emits for event #{event}" do 11 | calls = [] 12 | subject.on(event) { calls << :one } 13 | subject.on(event) { calls << :two } 14 | 15 | subject.emit(event, "param") 16 | 17 | expect(calls).to match_array [:one, :two] 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/http2-client/response_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe NetHttp2::Response do 4 | 5 | describe "attributes" do 6 | let(:headers) { double(:headers) } 7 | let(:body) { double(:body) } 8 | let(:response) { NetHttp2::Response.new(headers: headers, body: body) } 9 | 10 | subject { response } 11 | 12 | it { is_expected.to have_attributes(headers: headers) } 13 | it { is_expected.to have_attributes(body: body) } 14 | end 15 | 16 | describe "#status" do 17 | let(:status) { double(:status) } 18 | let(:response) { NetHttp2::Response.new(headers: { ':status' => status }) } 19 | 20 | subject { response.status } 21 | it { is_expected.to eq status } 22 | end 23 | 24 | describe "#ok?" do 25 | let(:response) { NetHttp2::Response.new(headers: { ':status' => status }) } 26 | 27 | subject { response.ok? } 28 | 29 | context "when status is 200" do 30 | let(:status) { "200" } 31 | it { is_expected.to eq true } 32 | end 33 | 34 | context "when status is not 200" do 35 | let(:status) { :other } 36 | it { is_expected.to eq false } 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Roberto Ostinelli. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /net-http2.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'net-http2/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "net-http2" 8 | spec.version = NetHttp2::VERSION 9 | spec.licenses = ['MIT'] 10 | spec.authors = ["Roberto Ostinelli"] 11 | spec.email = ["roberto@ostinelli.net"] 12 | spec.summary = %q{NetHttp2 is an HTTP2 client for Ruby.} 13 | spec.homepage = "http://github.com/ostinelli/net-http2" 14 | spec.required_ruby_version = '>=2.1.0' 15 | 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_dependency "http-2", "0.8.2" 23 | 24 | spec.add_development_dependency "bundler", "~> 1.3" 25 | spec.add_development_dependency "rake", "~> 10.0" 26 | spec.add_development_dependency "rspec", "~> 3.0" 27 | end 28 | -------------------------------------------------------------------------------- /spec/api/connection_timeouts_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Timeouts during connection" do 4 | let(:port) { 9516 } 5 | let(:client) do 6 | NetHttp2::Client.new( 7 | "#{scheme}://10.255.255.1:#{port}", # non-routable IP address to simulate timeout 8 | connect_timeout: 1 9 | ) 10 | end 11 | 12 | context "on non-SSL connections" do 13 | let(:scheme) { 'http' } 14 | 15 | it "raises after the custom timeout on tcp connections" do 16 | started_at = Time.now 17 | 18 | response = client.call(:get, '/path') rescue Errno::ETIMEDOUT 19 | 20 | expect(response).to eq Errno::ETIMEDOUT 21 | 22 | time_taken = Time.now - started_at 23 | expect(time_taken < 2).to eq true 24 | end 25 | end 26 | 27 | context "on SSL connections" do 28 | let(:scheme) { 'https' } 29 | 30 | it "raises after the custom timeout on SSL connections" do 31 | started_at = Time.now 32 | 33 | response = client.call(:get, '/path') rescue Errno::ETIMEDOUT 34 | 35 | expect(response).to eq Errno::ETIMEDOUT 36 | 37 | time_taken = Time.now - started_at 38 | expect(time_taken < 2).to eq true 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/support/priv/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEkTCCA3mgAwIBAgIJAMkveiGDiV1dMA0GCSqGSIb3DQEBBQUAMIGLMQswCQYD 3 | VQQGEwJJVDESMBAGA1UECBMJTG9tYmFyZGlhMQ8wDQYDVQQHEwZNaWxhbm8xEDAO 4 | BgNVBAoTB0Fwbm90aWMxDTALBgNVBAsTBFRlc3QxEjAQBgNVBAMTCWxvY2FsaG9z 5 | dDEiMCAGCSqGSIb3DQEJARYTYXBub3RpY0BleGFtcGxlLmNvbTAgFw0xNjA0MTgx 6 | MzMyMjdaGA8yMDY2MDQwNjEzMzIyN1owgYsxCzAJBgNVBAYTAklUMRIwEAYDVQQI 7 | EwlMb21iYXJkaWExDzANBgNVBAcTBk1pbGFubzEQMA4GA1UEChMHQXBub3RpYzEN 8 | MAsGA1UECxMEVGVzdDESMBAGA1UEAxMJbG9jYWxob3N0MSIwIAYJKoZIhvcNAQkB 9 | FhNhcG5vdGljQGV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 10 | CgKCAQEAzP+ystknR/iIFy5+GjCCB2mhehlEYz2oMsBy3xbEQPTfA5upXPbmHTXE 11 | Ze8UmAiMF7dbgfPzVHXQRM9P5ArbwOaQIoOfdjE8OdMY8k8iiSloohazPevSoiAy 12 | VqRONxGU7+JE4idkXYlpbcdvXGLDNFEfXkwHwhrYcU4sxymUtiqoZJbqSxOQYBr9 13 | PpGm+8xCKUiUcxGQTeZa/N4pyeGSEMhW938a87o6LpzIucPkD30yHRLX0JiODY+O 14 | 3OHawLUE8oyxXJpzn5zRJ0yo3JpuRvi1C8ImpT8o1UQV849vvXuxln3Nw76VbErQ 15 | gIw/16uanYPb8/ewuF8o4b+fM/CLSQIDAQABo4HzMIHwMB0GA1UdDgQWBBRkBR4i 16 | tZCFJtpTcPa+6qVfGfNxRDCBwAYDVR0jBIG4MIG1gBRkBR4itZCFJtpTcPa+6qVf 17 | GfNxRKGBkaSBjjCBizELMAkGA1UEBhMCSVQxEjAQBgNVBAgTCUxvbWJhcmRpYTEP 18 | MA0GA1UEBxMGTWlsYW5vMRAwDgYDVQQKEwdBcG5vdGljMQ0wCwYDVQQLEwRUZXN0 19 | MRIwEAYDVQQDEwlsb2NhbGhvc3QxIjAgBgkqhkiG9w0BCQEWE2Fwbm90aWNAZXhh 20 | bXBsZS5jb22CCQDJL3ohg4ldXTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUA 21 | A4IBAQC4WBUxHn5jilzvLk5rkNs3IflYHxjpi/JyKYpNmwaR5rnE+qIgz+UCZIOb 22 | cmIt1NdIFKmliucIAvG1xsDH1Ql/GuOe/40H2VOIfrN6qUcL136SGFnh2J/SBvGA 23 | M2L/XVlnh2MZM263E636FHJZjOBE+pBERPBmLXH5LgVg+bKMTAdDLyzQBnJlQDNn 24 | YZwy+PEj02hFlOBrRMnHSyEtprjJ3pnL6kGMU60BmY0i2vn9IyODpf5IKolNpctp 25 | 0u8QkQ1x6pBQzkGcGmYVdM0W5++3D3lhrnaprSOlmQa9b00bEQqk6I6rrrQyUhZ2 26 | xnkl+E6YfatqFHebOVSDh4PzeSjp 27 | -----END CERTIFICATE----- 28 | -------------------------------------------------------------------------------- /lib/http2_patch.rb: -------------------------------------------------------------------------------- 1 | require 'http/2/connection' 2 | 3 | # We are currently locked to using the Http2 library v0.8.2 since v0.8.3 still has some compatibility issues: 4 | # 5 | # 6 | # However, v0.8.2 had a memory leak that was reported in the following issues: 7 | # 8 | # 9 | # 10 | # Hence, this is a temporary monkey-patch to the HTTP2 library in order to solve the mentioned leak 11 | # while waiting to fix the issues on v0.8.3. 12 | 13 | module HTTP2 14 | 15 | class Connection 16 | 17 | private 18 | 19 | def activate_stream(id: nil, **args) 20 | connection_error(msg: 'Stream ID already exists') if @streams.key?(id) 21 | 22 | stream = Stream.new({ connection: self, id: id }.merge(args)) 23 | 24 | # Streams that are in the "open" state, or either of the "half closed" 25 | # states count toward the maximum number of streams that an endpoint is 26 | # permitted to open. 27 | stream.once(:active) { @active_stream_count += 1 } 28 | 29 | @streams_recently_closed ||= {} 30 | stream.once(:close) do 31 | @active_stream_count -= 1 32 | 33 | @streams_recently_closed.delete_if do |closed_stream_id, v| 34 | to_be_deleted = (Time.now - v) > 15 35 | @streams.delete(closed_stream_id) if to_be_deleted 36 | to_be_deleted 37 | end 38 | 39 | @streams_recently_closed[id] = Time.now 40 | end 41 | 42 | stream.on(:promise, &method(:promise)) if self.is_a? Server 43 | stream.on(:frame, &method(:send)) 44 | stream.on(:window_update, &method(:window_update)) 45 | 46 | @streams[id] = stream 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/support/priv/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAzP+ystknR/iIFy5+GjCCB2mhehlEYz2oMsBy3xbEQPTfA5up 3 | XPbmHTXEZe8UmAiMF7dbgfPzVHXQRM9P5ArbwOaQIoOfdjE8OdMY8k8iiSloohaz 4 | PevSoiAyVqRONxGU7+JE4idkXYlpbcdvXGLDNFEfXkwHwhrYcU4sxymUtiqoZJbq 5 | SxOQYBr9PpGm+8xCKUiUcxGQTeZa/N4pyeGSEMhW938a87o6LpzIucPkD30yHRLX 6 | 0JiODY+O3OHawLUE8oyxXJpzn5zRJ0yo3JpuRvi1C8ImpT8o1UQV849vvXuxln3N 7 | w76VbErQgIw/16uanYPb8/ewuF8o4b+fM/CLSQIDAQABAoIBAQC5+XLlm+lt+oOD 8 | /FK8catVDDhJK6kGG0Z/HGZaCy5p+3xiqpIgW4DxmPievSHCt2ZYkah7oZPr2KHj 9 | +utwZ4VrX//8v8onkI0hrGfiU3ZyVtWszsk3cLx7BpiET7UBcnrakTyKqs/7p5C0 10 | 3gwiFRsgWEQL6Q/UwUQArroiyI84HqURyIjzlLoPKS3uX5GlpUwJKEFgNXqw8ea+ 11 | yTOJOUfxz2wV3zAwZ5nAHa12XKSvWjXFXXc7Ydt+0xFgP3XJlyzmPt5w+5/YFxtR 12 | oI018NYc4ENZqgzVodVMUiynNBkUI9ZMakTnw5Y6FPFRQ4Z7zmcUv73GN+dYzz7y 13 | nD0Vy8uBAoGBAO1PdD3GrMJ+tUOjdd90Vd6HLx/Y4DcH4sTzoCqGSBBJGd7cyUTU 14 | 3hGfydXLAStAg6nq1Ascajzgyi7R+DNx/jzHDuM6r3NdLpHiTbL9n/yni8rlw16h 15 | om60cjgnQ0NH+aiZY1eeY0HYDDdCfy9mK/6RHStqCHsqwjXt2+wzEe8xAoGBAN0k 16 | yv8Z/FUfAN7SmWZ/XiqP/g0cZ4dcV1odK2dErmFxdyG6qzNsl9VsVTjZhipN89tB 17 | rRtVEN/s+n2JLRCMj/V7aXY1ix3MYR34Q2N32JrAtjgzFl7Z5jbNI6ed52Q0c8tE 18 | wfam1oTlAvY82Jlzpt/a7JjXCPj3wsfYB7+egkeZAoGAFZpcDJufcn0yZxvkSRlA 19 | D+fihFWr45aWMDO1aumaedENx9n1gIyYQqZ3Kz01uAhBdCBqeTB3A1+7SBPZMmW4 20 | LTQ5yLm46xmaebFOPXMVM1zVPv03kc/JB6bplu8MEn3k3lJIVtuWUZInWoh1J413 21 | h88SBre6WewEjgA/OvtTMKECgYEAxtUnA7ksjKhEkxPNwz+vvhsbdFRerXEURTzG 22 | 4qH5HDn1wEjjV2hDGCzAb039eJoAMNpbN6EDfCLJkge9kgyf/zsINrWrsI4rn9Ox 23 | W4TNJ08wR1V/vqayfAF0Fmg+PXV/y3q13vxhEroKMLXCli5LEyj24/Er6xZxdlfB 24 | l8OAJbkCgYB1POq2pmf1FR6ffO9tuRV85fKi2NJIP/hF0y9TtpqO++vXqjhDvyAC 25 | 1WEDoMyV+hwSuz/WW2ac7ocL9MkPBWnTzfAeK3dzE0Pf06gQleBdx1RHKzDNDj+v 26 | /kq6I6qScli8sKqertZ4rAl++trLM59B02fYzhNSer00A3N1aohxlg== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /spec/api/timeouts_with_sync_requests_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Timeouts with sync requests" do 4 | let(:port) { 9516 } 5 | let(:server) { NetHttp2::Dummy::Server.new(port: port) } 6 | let(:client) { NetHttp2::Client.new("http://localhost:#{port}") } 7 | 8 | before do 9 | server.listen 10 | server.on_req = Proc.new { |_req| sleep 3 } 11 | end 12 | 13 | after do 14 | client.close 15 | server.stop 16 | end 17 | 18 | it "returns nil when no response is received within the specified timeout" do 19 | response = client.call(:get, '/path', headers: { 'x-custom-header' => 'custom' }, timeout: 1) 20 | 21 | expect(response).to be_nil 22 | end 23 | 24 | it "returns nil when no sequential responses are received within the specified timeout" do 25 | responses = [] 26 | responses << client.call(:get, '/path', headers: { 'x-custom-header' => 'custom' }, timeout: 1) 27 | responses << client.call(:get, '/path', headers: { 'x-custom-header' => 'custom' }, timeout: 1) 28 | 29 | expect(responses.compact).to be_empty 30 | end 31 | 32 | it "returns nil when no concurrent responses are received within the specified timeout" do 33 | started_at = Time.now 34 | 35 | responses = [] 36 | thread = Thread.new { responses << client.call(:get, '/path', headers: { 'x-custom-header' => 'custom' }, timeout: 1) } 37 | responses << client.call(:get, '/path', headers: { 'x-custom-header' => 'custom' }, timeout: 1) 38 | 39 | thread.join 40 | 41 | time_taken = Time.now - started_at 42 | expect(time_taken < 2).to eq true 43 | 44 | expect(responses.compact).to be_empty 45 | end 46 | 47 | it "returns nil even if the client's main thread gets killed" do 48 | 49 | Thread.new do 50 | sleep 1 51 | client.close 52 | end 53 | 54 | response = client.call(:get, '/path', headers: { 'x-custom-header' => 'custom' }, timeout: 2) 55 | expect(response).to be_nil 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/api/ssl_requests_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "SSL Requests" do 4 | let(:port) { 9516 } 5 | let(:server) { NetHttp2::Dummy::Server.new(port: port, ssl: true) } 6 | let(:client) { NetHttp2::Client.new("https://localhost:#{port}") } 7 | 8 | before { server.listen } 9 | after do 10 | client.close 11 | server.stop 12 | end 13 | 14 | it "sends SSL GET requests" do 15 | request = nil 16 | server.on_req = Proc.new do |req| 17 | request = req 18 | 19 | NetHttp2::Response.new( 20 | headers: { ":status" => "200" }, 21 | body: "response body" 22 | ) 23 | end 24 | 25 | response = client.call(:get, '/path') 26 | 27 | expect(response).to be_a NetHttp2::Response 28 | expect(response.body).to eq "response body" 29 | 30 | expect(request).not_to be_nil 31 | expect(request.headers[":scheme"]).to eq "https" 32 | expect(request.headers[":method"]).to eq "GET" 33 | expect(request.headers[":path"]).to eq "/path" 34 | expect(request.headers[":authority"]).to eq "localhost:#{port}" 35 | end 36 | 37 | it "sends SSL GET requests and receives big bodies" do 38 | big_body = "a" * 100_000 39 | 40 | request = nil 41 | server.on_req = Proc.new do |req| 42 | request = req 43 | 44 | NetHttp2::Response.new( 45 | headers: { ":status" => "200" }, 46 | body: big_body.dup 47 | ) 48 | end 49 | 50 | response = client.call(:get, '/path') 51 | 52 | expect(response).to be_a NetHttp2::Response 53 | expect(response.body).to eq big_body 54 | 55 | expect(request).not_to be_nil 56 | expect(request.headers[":scheme"]).to eq "https" 57 | expect(request.headers[":method"]).to eq "GET" 58 | expect(request.headers[":path"]).to eq "/path" 59 | expect(request.headers[":authority"]).to eq "localhost:#{port}" 60 | end 61 | 62 | it "sends SSL POST requests with big bodies" do 63 | received_body = nil 64 | server.on_req = Proc.new do |req| 65 | received_body = req.body 66 | 67 | NetHttp2::Response.new( 68 | headers: { ":status" => "200" }, 69 | body: "response ok" 70 | ) 71 | end 72 | 73 | big_body = "a" * 100_000 74 | response = client.call(:post, '/path', body: big_body.dup, timeout: 5) 75 | 76 | expect(response).to be_a NetHttp2::Response 77 | expect(received_body).to eq big_body 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/net-http2/stream.rb: -------------------------------------------------------------------------------- 1 | module NetHttp2 2 | 3 | class Stream 4 | 5 | def initialize(options={}) 6 | @h2_stream = options[:h2_stream] 7 | @headers = {} 8 | @data = '' 9 | @request = nil 10 | @async = false 11 | @completed = false 12 | @mutex = Mutex.new 13 | @cv = ConditionVariable.new 14 | 15 | listen_for_headers 16 | listen_for_data 17 | listen_for_close 18 | end 19 | 20 | def id 21 | @h2_stream.id 22 | end 23 | 24 | def call_with(request) 25 | @request = request 26 | send_request_data 27 | sync_respond 28 | end 29 | 30 | def async_call_with(request) 31 | @request = request 32 | @async = true 33 | 34 | send_request_data 35 | end 36 | 37 | def completed? 38 | @completed 39 | end 40 | 41 | def async? 42 | @async 43 | end 44 | 45 | private 46 | 47 | def listen_for_headers 48 | @h2_stream.on(:headers) do |hs_array| 49 | hs = Hash[*hs_array.flatten] 50 | 51 | if async? 52 | @request.emit(:headers, hs) 53 | else 54 | @headers.merge!(hs) 55 | end 56 | end 57 | end 58 | 59 | def listen_for_data 60 | @h2_stream.on(:data) do |data| 61 | if async? 62 | @request.emit(:body_chunk, data) 63 | else 64 | @data << data 65 | end 66 | end 67 | end 68 | 69 | def listen_for_close 70 | @h2_stream.on(:close) do |data| 71 | @completed = true 72 | 73 | if async? 74 | @request.emit(:close, data) 75 | else 76 | @mutex.synchronize { @cv.signal } 77 | end 78 | end 79 | end 80 | 81 | def send_request_data 82 | headers = @request.headers 83 | body = @request.body 84 | 85 | if body 86 | @h2_stream.headers(headers, end_stream: false) 87 | @h2_stream.data(body, end_stream: true) 88 | else 89 | @h2_stream.headers(headers, end_stream: true) 90 | end 91 | end 92 | 93 | def sync_respond 94 | wait_for_completed 95 | 96 | NetHttp2::Response.new(headers: @headers, body: @data) if @completed 97 | end 98 | 99 | def wait_for_completed 100 | @mutex.synchronize { @cv.wait(@mutex, @request.timeout) } 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/net-http2/socket.rb: -------------------------------------------------------------------------------- 1 | module NetHttp2 2 | 3 | module Socket 4 | 5 | def self.create(uri, options) 6 | return ssl_socket(uri, options) if options[:ssl] 7 | return proxy_tcp_socket(uri, options) if options[:proxy_addr] 8 | 9 | tcp_socket(uri, options) 10 | end 11 | 12 | def self.ssl_socket(uri, options) 13 | tcp = if options[:proxy_addr] 14 | proxy_tcp_socket(uri, options) 15 | else 16 | tcp_socket(uri, options) 17 | end 18 | 19 | socket = OpenSSL::SSL::SSLSocket.new(tcp, options[:ssl_context]) 20 | socket.sync_close = true 21 | socket.hostname = options[:proxy_addr] || uri.host 22 | 23 | socket.connect 24 | 25 | socket 26 | end 27 | 28 | def self.tcp_socket(uri, options) 29 | family = ::Socket::AF_INET 30 | address = ::Socket.getaddrinfo(uri.host, nil, family).first[3] 31 | sockaddr = ::Socket.pack_sockaddr_in(uri.port, address) 32 | 33 | socket = ::Socket.new(family, ::Socket::SOCK_STREAM, 0) 34 | socket.setsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_NODELAY, 1) 35 | 36 | begin 37 | socket.connect_nonblock(sockaddr) 38 | rescue IO::WaitWritable 39 | if IO.select(nil, [socket], nil, options[:connect_timeout]) 40 | begin 41 | socket.connect_nonblock(sockaddr) 42 | rescue Errno::EISCONN 43 | # socket is connected 44 | rescue 45 | socket.close 46 | raise 47 | end 48 | else 49 | socket.close 50 | raise Errno::ETIMEDOUT 51 | end 52 | end 53 | 54 | socket 55 | end 56 | 57 | def self.proxy_tcp_socket(uri, options) 58 | proxy_addr = options[:proxy_addr] 59 | proxy_port = options[:proxy_port] 60 | proxy_user = options[:proxy_user] 61 | proxy_pass = options[:proxy_pass] 62 | 63 | proxy_uri = URI.parse("#{proxy_addr}:#{proxy_port}") 64 | proxy_socket = tcp_socket(proxy_uri, options) 65 | 66 | # The majority of proxies do not explicitly support HTTP/2 protocol, 67 | # while they successfully create a TCP tunnel 68 | # which can pass through binary data of HTTP/2 connection. 69 | # So we’ll keep HTTP/1.1 70 | http_version = '1.1' 71 | 72 | buf = "CONNECT #{uri.host}:#{uri.port} HTTP/#{http_version}\r\n" 73 | buf << "Host: #{uri.host}:#{uri.port}\r\n" 74 | if proxy_user 75 | credential = ["#{proxy_user}:#{proxy_pass}"].pack('m') 76 | credential.delete!("\r\n") 77 | buf << "Proxy-Authorization: Basic #{credential}\r\n" 78 | end 79 | buf << "\r\n" 80 | proxy_socket.write(buf) 81 | validate_proxy_response!(proxy_socket) 82 | 83 | proxy_socket 84 | end 85 | 86 | private 87 | 88 | def self.validate_proxy_response!(socket) 89 | result = '' 90 | loop do 91 | line = socket.gets 92 | break if !line || line.strip.empty? 93 | 94 | result << line 95 | end 96 | return if result =~ /HTTP\/\d(?:\.\d)?\s+2\d\d\s/ 97 | 98 | raise(StandardError, "Proxy connection failure:\n#{result}") 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /spec/http2-client/client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe NetHttp2::Client do 4 | 5 | describe "attributes" do 6 | let(:client) { NetHttp2::Client.new("http://localhost") } 7 | subject { client } 8 | it { is_expected.to have_attributes(uri: URI.parse("http://localhost")) } 9 | end 10 | 11 | describe "options" do 12 | 13 | describe "npm protocols in SSL" do 14 | 15 | subject { client.instance_variable_get(:@ssl_context) } 16 | 17 | context "when no custom SSL context is passed in" do 18 | let(:client) { NetHttp2::Client.new("http://localhost") } 19 | 20 | it "specifies the DRAFT protocol" do 21 | expect(subject.npn_protocols).to eq ['h2'] 22 | end 23 | end 24 | context "when a custom SSL context is passed in" do 25 | let(:ssl_context) { OpenSSL::SSL::SSLContext.new } 26 | let(:client) { NetHttp2::Client.new("http://localhost", ssl_context: ssl_context) } 27 | 28 | it "specifies the DRAFT protocol" do 29 | expect(subject.npn_protocols).to eq ['h2'] 30 | expect(ssl_context.npn_protocols).to eq ['h2'] 31 | end 32 | end 33 | end 34 | end 35 | 36 | describe "#ssl?" do 37 | let(:client) { NetHttp2::Client.new(url) } 38 | 39 | subject { client.ssl? } 40 | 41 | context "when URL has an http scheme" do 42 | let(:url) { "http://localhost" } 43 | it { is_expected.to eq false } 44 | end 45 | 46 | context "when URL has an https scheme" do 47 | let(:url) { "https://localhost" } 48 | it { is_expected.to eq true } 49 | end 50 | end 51 | 52 | describe '#proxy_tcp_socket' do 53 | let(:target_location) { 'bigbrother.com' } 54 | let(:target_port) { '9999' } 55 | let(:uri) { URI.parse("https://#{target_location}:#{target_port}") } 56 | let(:options) { 57 | { 58 | ssl_context: OpenSSL::SSL::SSLContext.new, 59 | proxy_addr: 'http://hidemyass.proxy.com', 60 | proxy_port: '3213', 61 | proxy_user: 'someuser', 62 | proxy_pass: 'somepass' 63 | } 64 | } 65 | let(:fake_tcp_socket) { double } 66 | let(:fake_ssl_socket) { double(:sync_close= => 'ok', :connect => 'ok') } 67 | 68 | it 'establish connection through proxy with credentials' do 69 | expect(OpenSSL::SSL::SSLSocket).to receive(:new) { fake_ssl_socket } 70 | expect(NetHttp2::Socket).to receive(:tcp_socket). 71 | with(URI.parse("#{options[:proxy_addr]}:#{options[:proxy_port]}"), options). 72 | exactly(:once) { fake_tcp_socket } 73 | expect(fake_tcp_socket).to receive(:write).with( 74 | "CONNECT #{target_location}:#{target_port} HTTP/1.1\r\n"\ 75 | "Host: #{target_location}:#{target_port}\r\n"\ 76 | "Proxy-Authorization: Basic c29tZXVzZXI6c29tZXBhc3M=\r\n\r\n" 77 | ) 78 | expect(fake_tcp_socket).to receive(:gets). 79 | and_return('HTTP/1.1 200 OK', '') 80 | expect(fake_ssl_socket).to receive(:hostname=).with(options[:proxy_addr]) 81 | 82 | NetHttp2::Socket.ssl_socket(uri, options) 83 | end 84 | end 85 | 86 | describe "Subscription & emission" do 87 | subject { NetHttp2::Client.new("http://localhost") } 88 | it_behaves_like "a class that implements events subscription & emission" 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/support/priv/apn.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEkTCCA3mgAwIBAgIJAMkveiGDiV1dMA0GCSqGSIb3DQEBBQUAMIGLMQswCQYD 3 | VQQGEwJJVDESMBAGA1UECBMJTG9tYmFyZGlhMQ8wDQYDVQQHEwZNaWxhbm8xEDAO 4 | BgNVBAoTB0Fwbm90aWMxDTALBgNVBAsTBFRlc3QxEjAQBgNVBAMTCWxvY2FsaG9z 5 | dDEiMCAGCSqGSIb3DQEJARYTYXBub3RpY0BleGFtcGxlLmNvbTAgFw0xNjA0MTgx 6 | MzMyMjdaGA8yMDY2MDQwNjEzMzIyN1owgYsxCzAJBgNVBAYTAklUMRIwEAYDVQQI 7 | EwlMb21iYXJkaWExDzANBgNVBAcTBk1pbGFubzEQMA4GA1UEChMHQXBub3RpYzEN 8 | MAsGA1UECxMEVGVzdDESMBAGA1UEAxMJbG9jYWxob3N0MSIwIAYJKoZIhvcNAQkB 9 | FhNhcG5vdGljQGV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 10 | CgKCAQEAzP+ystknR/iIFy5+GjCCB2mhehlEYz2oMsBy3xbEQPTfA5upXPbmHTXE 11 | Ze8UmAiMF7dbgfPzVHXQRM9P5ArbwOaQIoOfdjE8OdMY8k8iiSloohazPevSoiAy 12 | VqRONxGU7+JE4idkXYlpbcdvXGLDNFEfXkwHwhrYcU4sxymUtiqoZJbqSxOQYBr9 13 | PpGm+8xCKUiUcxGQTeZa/N4pyeGSEMhW938a87o6LpzIucPkD30yHRLX0JiODY+O 14 | 3OHawLUE8oyxXJpzn5zRJ0yo3JpuRvi1C8ImpT8o1UQV849vvXuxln3Nw76VbErQ 15 | gIw/16uanYPb8/ewuF8o4b+fM/CLSQIDAQABo4HzMIHwMB0GA1UdDgQWBBRkBR4i 16 | tZCFJtpTcPa+6qVfGfNxRDCBwAYDVR0jBIG4MIG1gBRkBR4itZCFJtpTcPa+6qVf 17 | GfNxRKGBkaSBjjCBizELMAkGA1UEBhMCSVQxEjAQBgNVBAgTCUxvbWJhcmRpYTEP 18 | MA0GA1UEBxMGTWlsYW5vMRAwDgYDVQQKEwdBcG5vdGljMQ0wCwYDVQQLEwRUZXN0 19 | MRIwEAYDVQQDEwlsb2NhbGhvc3QxIjAgBgkqhkiG9w0BCQEWE2Fwbm90aWNAZXhh 20 | bXBsZS5jb22CCQDJL3ohg4ldXTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUA 21 | A4IBAQC4WBUxHn5jilzvLk5rkNs3IflYHxjpi/JyKYpNmwaR5rnE+qIgz+UCZIOb 22 | cmIt1NdIFKmliucIAvG1xsDH1Ql/GuOe/40H2VOIfrN6qUcL136SGFnh2J/SBvGA 23 | M2L/XVlnh2MZM263E636FHJZjOBE+pBERPBmLXH5LgVg+bKMTAdDLyzQBnJlQDNn 24 | YZwy+PEj02hFlOBrRMnHSyEtprjJ3pnL6kGMU60BmY0i2vn9IyODpf5IKolNpctp 25 | 0u8QkQ1x6pBQzkGcGmYVdM0W5++3D3lhrnaprSOlmQa9b00bEQqk6I6rrrQyUhZ2 26 | xnkl+E6YfatqFHebOVSDh4PzeSjp 27 | -----END CERTIFICATE----- 28 | -----BEGIN RSA PRIVATE KEY----- 29 | MIIEpAIBAAKCAQEAzP+ystknR/iIFy5+GjCCB2mhehlEYz2oMsBy3xbEQPTfA5up 30 | XPbmHTXEZe8UmAiMF7dbgfPzVHXQRM9P5ArbwOaQIoOfdjE8OdMY8k8iiSloohaz 31 | PevSoiAyVqRONxGU7+JE4idkXYlpbcdvXGLDNFEfXkwHwhrYcU4sxymUtiqoZJbq 32 | SxOQYBr9PpGm+8xCKUiUcxGQTeZa/N4pyeGSEMhW938a87o6LpzIucPkD30yHRLX 33 | 0JiODY+O3OHawLUE8oyxXJpzn5zRJ0yo3JpuRvi1C8ImpT8o1UQV849vvXuxln3N 34 | w76VbErQgIw/16uanYPb8/ewuF8o4b+fM/CLSQIDAQABAoIBAQC5+XLlm+lt+oOD 35 | /FK8catVDDhJK6kGG0Z/HGZaCy5p+3xiqpIgW4DxmPievSHCt2ZYkah7oZPr2KHj 36 | +utwZ4VrX//8v8onkI0hrGfiU3ZyVtWszsk3cLx7BpiET7UBcnrakTyKqs/7p5C0 37 | 3gwiFRsgWEQL6Q/UwUQArroiyI84HqURyIjzlLoPKS3uX5GlpUwJKEFgNXqw8ea+ 38 | yTOJOUfxz2wV3zAwZ5nAHa12XKSvWjXFXXc7Ydt+0xFgP3XJlyzmPt5w+5/YFxtR 39 | oI018NYc4ENZqgzVodVMUiynNBkUI9ZMakTnw5Y6FPFRQ4Z7zmcUv73GN+dYzz7y 40 | nD0Vy8uBAoGBAO1PdD3GrMJ+tUOjdd90Vd6HLx/Y4DcH4sTzoCqGSBBJGd7cyUTU 41 | 3hGfydXLAStAg6nq1Ascajzgyi7R+DNx/jzHDuM6r3NdLpHiTbL9n/yni8rlw16h 42 | om60cjgnQ0NH+aiZY1eeY0HYDDdCfy9mK/6RHStqCHsqwjXt2+wzEe8xAoGBAN0k 43 | yv8Z/FUfAN7SmWZ/XiqP/g0cZ4dcV1odK2dErmFxdyG6qzNsl9VsVTjZhipN89tB 44 | rRtVEN/s+n2JLRCMj/V7aXY1ix3MYR34Q2N32JrAtjgzFl7Z5jbNI6ed52Q0c8tE 45 | wfam1oTlAvY82Jlzpt/a7JjXCPj3wsfYB7+egkeZAoGAFZpcDJufcn0yZxvkSRlA 46 | D+fihFWr45aWMDO1aumaedENx9n1gIyYQqZ3Kz01uAhBdCBqeTB3A1+7SBPZMmW4 47 | LTQ5yLm46xmaebFOPXMVM1zVPv03kc/JB6bplu8MEn3k3lJIVtuWUZInWoh1J413 48 | h88SBre6WewEjgA/OvtTMKECgYEAxtUnA7ksjKhEkxPNwz+vvhsbdFRerXEURTzG 49 | 4qH5HDn1wEjjV2hDGCzAb039eJoAMNpbN6EDfCLJkge9kgyf/zsINrWrsI4rn9Ox 50 | W4TNJ08wR1V/vqayfAF0Fmg+PXV/y3q13vxhEroKMLXCli5LEyj24/Er6xZxdlfB 51 | l8OAJbkCgYB1POq2pmf1FR6ffO9tuRV85fKi2NJIP/hF0y9TtpqO++vXqjhDvyAC 52 | 1WEDoMyV+hwSuz/WW2ac7ocL9MkPBWnTzfAeK3dzE0Pf06gQleBdx1RHKzDNDj+v 53 | /kq6I6qScli8sKqertZ4rAl++trLM59B02fYzhNSer00A3N1aohxlg== 54 | -----END RSA PRIVATE KEY----- 55 | -------------------------------------------------------------------------------- /spec/support/dummy_server.rb: -------------------------------------------------------------------------------- 1 | module NetHttp2 2 | 3 | module Dummy 4 | 5 | class Request 6 | attr_accessor :body 7 | attr_reader :headers 8 | 9 | def initialize 10 | @body = '' 11 | end 12 | 13 | def import_headers(h) 14 | @headers = Hash[*h.flatten] 15 | end 16 | end 17 | 18 | class Server 19 | include NetHttp2::ApiHelpers 20 | 21 | DRAFT = 'h2' 22 | 23 | attr_accessor :on_req 24 | 25 | def initialize(options={}) 26 | @is_ssl = options[:ssl] 27 | @port = options[:port] 28 | @listen_thread = nil 29 | @threads = [] 30 | end 31 | 32 | def listen 33 | @server = new_server 34 | 35 | @listen_thread = Thread.new do 36 | loop do 37 | Thread.start(@server.accept) do |socket| 38 | @threads << Thread.current 39 | handle(socket) 40 | end.tap { |t| t.abort_on_exception = true } 41 | end 42 | end.tap { |t| t.abort_on_exception = true } 43 | end 44 | 45 | def stop 46 | exit_thread(@listen_thread) 47 | @threads.each { |t| exit_thread(t) } 48 | 49 | @server.close 50 | 51 | @server = nil 52 | @ssl_context = nil 53 | @listen_thread = nil 54 | @threads = [] 55 | end 56 | 57 | private 58 | 59 | def cert_file_path 60 | File.expand_path('../priv/server.crt', __FILE__) 61 | end 62 | 63 | def key_file_path 64 | File.expand_path('../priv/server.key', __FILE__) 65 | end 66 | 67 | def handle(socket) 68 | conn = HTTP2::Server.new 69 | 70 | conn.on(:frame) { |bytes| socket.write(bytes) } 71 | conn.on(:stream) do |stream| 72 | req = NetHttp2::Dummy::Request.new 73 | 74 | stream.on(:headers) { |h| req.import_headers(h) } 75 | stream.on(:data) { |d| req.body << d } 76 | stream.on(:half_close) do 77 | 78 | # callbacks 79 | res = if on_req 80 | on_req.call(req, stream, socket) 81 | else 82 | NetHttp2::Response.new( 83 | headers: { ":status" => "200" }, 84 | body: "response body" 85 | ) 86 | end 87 | 88 | if res.is_a?(Response) 89 | stream.headers({ 90 | ':status' => res.headers[":status"], 91 | 'content-length' => res.body.bytesize.to_s, 92 | 'content-type' => 'text/plain', 93 | }, end_stream: false) 94 | 95 | stream.data(res.body, end_stream: true) 96 | end 97 | end 98 | end 99 | 100 | while socket && !socket.closed? && !socket.eof? 101 | data = socket.read_nonblock(1024) 102 | conn << data 103 | end 104 | 105 | socket.close unless socket.closed? 106 | end 107 | 108 | def new_server 109 | s = TCPServer.new(@port) 110 | @is_ssl ? OpenSSL::SSL::SSLServer.new(s, ssl_context) : s 111 | end 112 | 113 | def ssl_context 114 | @ssl_context ||= begin 115 | ctx = OpenSSL::SSL::SSLContext.new 116 | ctx.cert = OpenSSL::X509::Certificate.new(File.open(cert_file_path)) 117 | ctx.key = OpenSSL::PKey::RSA.new(File.open(key_file_path)) 118 | ctx.npn_protocols = [DRAFT] 119 | ctx 120 | end 121 | end 122 | 123 | def exit_thread(thread) 124 | return unless thread 125 | thread.exit 126 | thread.join 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/net-http2/request.rb: -------------------------------------------------------------------------------- 1 | require 'cgi' 2 | 3 | module NetHttp2 4 | 5 | class Request 6 | 7 | include Callbacks 8 | 9 | DEFAULT_TIMEOUT = 60 10 | 11 | attr_reader :method, :uri, :path, :params, :body, :timeout 12 | 13 | def initialize(method, uri, path, options={}) 14 | @method = method 15 | @uri = uri 16 | @path = path 17 | @params = options[:params] || {} 18 | @body = options[:body] 19 | @headers = options[:headers] || {} 20 | @timeout = options[:timeout] || DEFAULT_TIMEOUT 21 | 22 | @events = {} 23 | end 24 | 25 | def headers 26 | @headers.merge!({ 27 | ':scheme' => @uri.scheme, 28 | ':method' => @method.to_s.upcase, 29 | ':path' => full_path, 30 | }) 31 | 32 | @headers.merge!(':authority' => "#{@uri.host}:#{@uri.port}") unless @headers[':authority'] 33 | 34 | if @body 35 | @headers.merge!('content-length' => @body.bytesize) 36 | else 37 | @headers.delete('content-length') 38 | end 39 | 40 | @headers.update(@headers) { |_k, v| v.to_s } 41 | 42 | @headers 43 | end 44 | 45 | def full_path 46 | path = @path 47 | path += "?#{to_query(@params)}" unless @params.empty? 48 | path 49 | end 50 | 51 | private 52 | 53 | # The to_param and to_query code here below is a free adaptation from the original code in: 54 | # 55 | # released under the following MIT license: 56 | # 57 | # Copyright (c) 2005-2016 David Heinemeier Hansson 58 | # 59 | # Permission is hereby granted, free of charge, to any person obtaining 60 | # a copy of this software and associated documentation files (the 61 | # "Software"), to deal in the Software without restriction, including 62 | # without limitation the rights to use, copy, modify, merge, publish, 63 | # distribute, sublicense, and/or sell copies of the Software, and to 64 | # permit persons to whom the Software is furnished to do so, subject to 65 | # the following conditions: 66 | # 67 | # The above copyright notice and this permission notice shall be 68 | # included in all copies or substantial portions of the Software. 69 | # 70 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 71 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 72 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 73 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 74 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 75 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 76 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 77 | 78 | def to_param(element) 79 | if element.is_a?(TrueClass) || element.is_a?(FalseClass) || element.is_a?(NilClass) 80 | element 81 | elsif element.is_a?(Array) 82 | element.collect(&:to_param).join '/' 83 | else 84 | element.to_s.strip 85 | end 86 | end 87 | 88 | def to_query(element, namespace_or_key = nil) 89 | if element.is_a?(Hash) 90 | element.collect do |key, value| 91 | unless (value.is_a?(Hash) || value.is_a?(Array)) && value.empty? 92 | to_query(value, namespace_or_key ? "#{namespace_or_key}[#{key}]" : key) 93 | end 94 | end.compact.sort! * '&' 95 | elsif element.is_a?(Array) 96 | prefix = "#{namespace_or_key}[]" 97 | 98 | if element.empty? 99 | to_query(nil, prefix) 100 | else 101 | element.collect { |value| to_query(value, prefix) }.join '&' 102 | end 103 | else 104 | "#{CGI.escape(to_param(namespace_or_key))}=#{CGI.escape(to_param(element).to_s)}" 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/net-http2/client.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require 'openssl' 3 | require 'uri' 4 | require 'http/2' 5 | 6 | module NetHttp2 7 | 8 | DRAFT = 'h2' 9 | PROXY_SETTINGS_KEYS = [:proxy_addr, :proxy_port, :proxy_user, :proxy_pass] 10 | 11 | class Client 12 | 13 | include Callbacks 14 | 15 | attr_reader :uri 16 | 17 | def initialize(url, options={}) 18 | @uri = URI.parse(url) 19 | @connect_timeout = options[:connect_timeout] || 60 20 | @ssl_context = add_npn_to_context(options[:ssl_context] || OpenSSL::SSL::SSLContext.new) 21 | 22 | PROXY_SETTINGS_KEYS.each do |key| 23 | instance_variable_set("@#{key}", options[key]) if options[key] 24 | end 25 | 26 | @is_ssl = (@uri.scheme == 'https') 27 | 28 | @mutex = Mutex.new 29 | init_vars 30 | end 31 | 32 | def call(method, path, options={}) 33 | request = prepare_request(method, path, options) 34 | ensure_open 35 | new_stream.call_with request 36 | end 37 | 38 | def call_async(request) 39 | ensure_open 40 | stream = new_monitored_stream_for request 41 | stream.async_call_with request 42 | end 43 | 44 | def prepare_request(method, path, options={}) 45 | NetHttp2::Request.new(method, @uri, path, options) 46 | end 47 | 48 | def ssl? 49 | @is_ssl 50 | end 51 | 52 | def close 53 | exit_thread(@socket_thread) 54 | init_vars 55 | end 56 | 57 | def join 58 | while !@streams.empty? do 59 | sleep 0.05 60 | end 61 | end 62 | 63 | private 64 | 65 | def init_vars 66 | @mutex.synchronize do 67 | @socket.close if @socket && !@socket.closed? 68 | 69 | @h2 = nil 70 | @socket = nil 71 | @socket_thread = nil 72 | @first_data_sent = false 73 | @streams = {} 74 | end 75 | end 76 | 77 | def new_stream 78 | NetHttp2::Stream.new(h2_stream: h2.new_stream) 79 | rescue StandardError => e 80 | close 81 | raise e 82 | end 83 | 84 | def new_monitored_stream_for(request) 85 | stream = new_stream 86 | 87 | @streams[stream.id] = true 88 | request.on(:close) { @streams.delete(stream.id) } 89 | 90 | stream 91 | end 92 | 93 | def ensure_open 94 | @mutex.synchronize do 95 | 96 | return if @socket_thread 97 | 98 | @socket = new_socket 99 | 100 | @socket_thread = Thread.new do 101 | begin 102 | socket_loop 103 | 104 | rescue EOFError 105 | # socket closed 106 | init_vars 107 | callback_or_raise SocketError.new('Socket was remotely closed') 108 | 109 | rescue Exception => e 110 | # error on socket 111 | init_vars 112 | callback_or_raise e 113 | end 114 | end.tap { |t| t.abort_on_exception = true } 115 | end 116 | end 117 | 118 | def callback_or_raise(exception) 119 | if callback_events.keys.include?(:error) 120 | emit(:error, exception) 121 | else 122 | raise exception 123 | end 124 | end 125 | 126 | def socket_loop 127 | 128 | ensure_sent_before_receiving 129 | 130 | loop do 131 | 132 | begin 133 | data_received = @socket.read_nonblock(1024) 134 | h2 << data_received 135 | rescue IO::WaitReadable 136 | IO.select([@socket]) 137 | retry 138 | rescue IO::WaitWritable 139 | IO.select(nil, [@socket]) 140 | retry 141 | end 142 | end 143 | end 144 | 145 | def new_socket 146 | options = { 147 | ssl: ssl?, ssl_context: @ssl_context, connect_timeout: @connect_timeout 148 | } 149 | PROXY_SETTINGS_KEYS.each { |k| options[k] = instance_variable_get("@#{k}") } 150 | NetHttp2::Socket.create(@uri, options) 151 | end 152 | 153 | def ensure_sent_before_receiving 154 | while !@first_data_sent 155 | sleep 0.01 156 | end 157 | end 158 | 159 | def h2 160 | @h2 ||= HTTP2::Client.new.tap do |h2| 161 | h2.on(:frame) do |bytes| 162 | @mutex.synchronize do 163 | @socket.write(bytes) 164 | @socket.flush 165 | 166 | @first_data_sent = true 167 | end 168 | end 169 | end 170 | end 171 | 172 | def add_npn_to_context(ctx) 173 | ctx.npn_protocols = [DRAFT] 174 | ctx.npn_select_cb = lambda do |protocols| 175 | DRAFT if protocols.include?(DRAFT) 176 | end 177 | ctx 178 | end 179 | 180 | def exit_thread(thread) 181 | return unless thread 182 | thread.exit 183 | thread.join 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /spec/api/sending_sync_requests_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Sending sync requests" do 4 | let(:port) { 9516 } 5 | let(:server) { NetHttp2::Dummy::Server.new(port: port) } 6 | let(:client) { NetHttp2::Client.new("http://localhost:#{port}") } 7 | 8 | before { server.listen } 9 | 10 | after do 11 | client.close 12 | server.stop 13 | end 14 | 15 | it "sends a request without a body" do 16 | request = nil 17 | server.on_req = Proc.new do |req| 18 | request = req 19 | 20 | NetHttp2::Response.new( 21 | headers: { ":status" => "200" }, 22 | body: "response body" 23 | ) 24 | end 25 | 26 | response = client.call(:get, '/path', 27 | headers: { 'x-custom-header' => 'custom' } 28 | ) 29 | 30 | expect(response).to be_a NetHttp2::Response 31 | expect(response.body).to eq "response body" 32 | 33 | expect(request).not_to be_nil 34 | expect(request.headers[":scheme"]).to eq "http" 35 | expect(request.headers[":method"]).to eq "GET" 36 | expect(request.headers[":path"]).to eq "/path" 37 | expect(request.headers[":authority"]).to eq "localhost:#{port}" 38 | expect(request.headers["x-custom-header"]).to eq "custom" 39 | end 40 | 41 | it "sends a request with a body" do 42 | request = nil 43 | server.on_req = Proc.new do |req| 44 | request = req 45 | 46 | NetHttp2::Response.new( 47 | headers: { ":status" => "200" }, 48 | body: "response body" 49 | ) 50 | end 51 | 52 | response = client.call(:post, '/path', 53 | body: "body", 54 | headers: { 'x-custom-header' => 'custom' } 55 | ) 56 | 57 | expect(response).to be_a NetHttp2::Response 58 | expect(response.body).to eq "response body" 59 | 60 | expect(request).not_to be_nil 61 | expect(request.headers[":scheme"]).to eq "http" 62 | expect(request.headers[":method"]).to eq "POST" 63 | expect(request.headers[":path"]).to eq "/path" 64 | expect(request.headers[":authority"]).to eq "localhost:#{port}" 65 | expect(request.headers["x-custom-header"]).to eq "custom" 66 | 67 | expect(request.body).to eq "body" 68 | end 69 | 70 | it "sends multiple GET requests sequentially" do 71 | requests = [] 72 | server.on_req = Proc.new do |req| 73 | requests << req 74 | 75 | NetHttp2::Response.new( 76 | headers: { ":status" => "200" }, 77 | body: "response for #{req.headers[':path']}" 78 | ) 79 | end 80 | 81 | response_1 = client.call(:get, '/path1') 82 | response_2 = client.call(:get, '/path2') 83 | 84 | expect(response_1).to be_a NetHttp2::Response 85 | expect(response_1.body).to eq "response for /path1" 86 | expect(response_2).to be_a NetHttp2::Response 87 | expect(response_2.body).to eq "response for /path2" 88 | 89 | request_1, request_2 = requests 90 | expect(request_1).not_to be_nil 91 | expect(request_2).not_to be_nil 92 | end 93 | 94 | it "sends multiple GET requests concurrently" do 95 | requests = [] 96 | server.on_req = Proc.new do |req| 97 | requests << req 98 | 99 | NetHttp2::Response.new( 100 | headers: { ":status" => "200" }, 101 | body: "response for #{req.headers[':path']}" 102 | ) 103 | end 104 | 105 | response_1 = nil 106 | thread = Thread.new { response_1 = client.call(:get, '/path1') } 107 | response_2 = client.call(:get, '/path2') 108 | 109 | thread.join 110 | 111 | expect(response_1).to be_a NetHttp2::Response 112 | expect(response_1.body).to eq "response for /path1" 113 | expect(response_2).to be_a NetHttp2::Response 114 | expect(response_2.body).to eq "response for /path2" 115 | 116 | request_1, request_2 = requests 117 | expect(request_1).not_to be_nil 118 | expect(request_2).not_to be_nil 119 | end 120 | 121 | it "sends GET requests and receives big bodies" do 122 | big_body = "a" * 100_000 123 | 124 | server.on_req = Proc.new do |_req| 125 | NetHttp2::Response.new( 126 | headers: { ":status" => "200" }, 127 | body: big_body.dup 128 | ) 129 | end 130 | 131 | response = client.call(:get, '/path', timeout: 5) 132 | 133 | expect(response).to be_a NetHttp2::Response 134 | expect(response.body).to eq big_body 135 | end 136 | 137 | it "sends POST requests with big bodies" do 138 | received_body = nil 139 | server.on_req = Proc.new do |req| 140 | received_body = req.body 141 | 142 | NetHttp2::Response.new( 143 | headers: { ":status" => "200" }, 144 | body: "response ok" 145 | ) 146 | end 147 | 148 | big_body = "a" * 100_000 149 | response = client.call(:post, '/path', body: big_body.dup, timeout: 5) 150 | 151 | expect(response).to be_a NetHttp2::Response 152 | expect(received_body).to eq big_body 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /spec/http2-client/request_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe NetHttp2::Request do 4 | let(:method) { :get } 5 | let(:uri) { URI.parse("http://localhost") } 6 | let(:path) { "/path" } 7 | let(:params) { { one: 1, two: 2 } } 8 | let(:body) { "request body" } 9 | let(:headers) { {} } 10 | let(:timeout) { 5 } 11 | let(:request) do 12 | NetHttp2::Request.new(method, uri, path, params: params, headers: headers, body: body, timeout: timeout) 13 | end 14 | 15 | describe "attributes" do 16 | 17 | subject { request } 18 | 19 | it { is_expected.to have_attributes(method: method) } 20 | it { is_expected.to have_attributes(uri: uri) } 21 | it { is_expected.to have_attributes(path: path) } 22 | it { is_expected.to have_attributes(params: params) } 23 | it { is_expected.to have_attributes(body: body) } 24 | it { is_expected.to have_attributes(timeout: timeout) } 25 | end 26 | 27 | describe "#headers" do 28 | let(:full_path) { '/a/full/path' } 29 | 30 | before { allow(request).to receive(:full_path) { full_path } } 31 | 32 | subject { request.headers } 33 | 34 | context "when a body has been specified" do 35 | let(:method) { :post } 36 | let(:body) { "request body" } 37 | 38 | context "when no headers are passed" do 39 | let(:headers) { {} } 40 | 41 | it { is_expected.to eq( 42 | { 43 | ':scheme' => 'http', 44 | ':method' => 'POST', 45 | ':path' => full_path, 46 | ':authority' => 'localhost:80', 47 | 'content-length' => '12' 48 | } 49 | ) } 50 | end 51 | 52 | context "when headers are passed" do 53 | let(:headers) do 54 | { 55 | ':scheme' => 'https', 56 | ':method' => 'OTHER', 57 | ':path' => '/another', 58 | ':authority' => 'rob.local:80', 59 | 'x-custom' => 'custom', 60 | 'x-custom-number' => 3, 61 | 'content-length' => '999' 62 | } 63 | end 64 | 65 | it { is_expected.to eq( 66 | { 67 | ':scheme' => 'http', 68 | ':method' => 'POST', 69 | ':path' => full_path, 70 | ':authority' => 'rob.local:80', 71 | 'x-custom' => 'custom', 72 | 'x-custom-number' => '3', 73 | 'content-length' => '12' 74 | } 75 | ) } 76 | end 77 | end 78 | 79 | context "when no body has been specified" do 80 | let(:method) { :get } 81 | let(:body) { nil } 82 | 83 | context "when no headers are passed" do 84 | let(:headers) { {} } 85 | 86 | it { is_expected.to eq( 87 | { 88 | ':scheme' => 'http', 89 | ':method' => 'GET', 90 | ':path' => full_path, 91 | ':authority' => 'localhost:80' 92 | } 93 | ) } 94 | end 95 | 96 | context "when headers are passed" do 97 | let(:headers) do 98 | { 99 | ':scheme' => 'https', 100 | ':method' => 'OTHER', 101 | ':path' => '/another', 102 | ':authority' => 'rob.local:80', 103 | 'x-custom' => 'custom', 104 | 'x-custom-number' => 3, 105 | 'content-length' => '999' 106 | } 107 | end 108 | 109 | it { is_expected.to eq( 110 | { 111 | ':scheme' => 'http', 112 | ':method' => 'GET', 113 | ':path' => full_path, 114 | ':authority' => 'rob.local:80', 115 | 'x-custom' => 'custom', 116 | 'x-custom-number' => '3' 117 | } 118 | ) } 119 | end 120 | end 121 | end 122 | 123 | describe "#full_path" do 124 | 125 | def request_for_params(params) 126 | NetHttp2::Request.new(:get, 'http://example.com', '/my_path', params: params) 127 | end 128 | 129 | it "converts params into properly formed query strings" do 130 | req = request_for_params(a: "a", b: ["c", "d", "e"]) 131 | expect(req.full_path).to eq "/my_path?a=a&b%5B%5D=c&b%5B%5D=d&b%5B%5D=e" 132 | 133 | req = request_for_params(a: "a", :b => [{ :c => "c", :d => "d" }, { :e => "e", :f => "f" }]) 134 | expect(req.full_path).to eq "/my_path?a=a&b%5B%5D%5Bc%5D=c&b%5B%5D%5Bd%5D=d&b%5B%5D%5Be%5D=e&b%5B%5D%5Bf%5D=f" 135 | 136 | req = request_for_params(a: "a", :b => { :c => "c", :d => "d" }) 137 | expect(req.full_path).to eq "/my_path?a=a&b%5Bc%5D=c&b%5Bd%5D=d" 138 | 139 | req = request_for_params(a: "a", :b => { :c => "c", :d => true }) 140 | expect(req.full_path).to eq "/my_path?a=a&b%5Bc%5D=c&b%5Bd%5D=true" 141 | 142 | req = request_for_params(a: "a", :b => { :c => "c", :d => true }, :e => []) 143 | expect(req.full_path).to eq "/my_path?a=a&b%5Bc%5D=c&b%5Bd%5D=true" 144 | 145 | req = request_for_params(nil) 146 | expect(req.full_path).to eq "/my_path" 147 | end 148 | end 149 | 150 | describe "Subscription & emission" do 151 | subject { NetHttp2::Client.new("http://localhost") } 152 | it_behaves_like "a class that implements events subscription & emission" 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/ostinelli/net-http2.svg?branch=master)](https://travis-ci.org/ostinelli/net-http2) 2 | [![Code Climate](https://codeclimate.com/github/ostinelli/net-http2/badges/gpa.svg)](https://codeclimate.com/github/ostinelli/net-http2) 3 | [![Gem Version](https://badge.fury.io/rb/net-http2.svg)](https://badge.fury.io/rb/net-http2) 4 | 5 | # NetHttp2 6 | 7 | NetHttp2 is an HTTP/2 client for Ruby. 8 | 9 | ## Installation 10 | Just install the gem: 11 | 12 | ``` 13 | $ gem install net-http2 14 | ``` 15 | 16 | Or add it to your Gemfile: 17 | 18 | ```ruby 19 | gem 'net-http2' 20 | ``` 21 | 22 | ## Usage 23 | NetHttp2 can perform sync and async calls. Sync calls are very similar to the HTTP/1 calls, while async calls take advantage of the streaming properties of HTTP/2. 24 | 25 | To perform a sync call: 26 | ```ruby 27 | require 'net-http2' 28 | 29 | # create a client 30 | client = NetHttp2::Client.new("http://nghttp2.org") 31 | 32 | # send request 33 | response = client.call(:get, '/') 34 | 35 | # read the response 36 | response.ok? # => true 37 | response.status # => '200' 38 | response.headers # => {":status"=>"200"} 39 | response.body # => "A body" 40 | 41 | # close the connection 42 | client.close 43 | ``` 44 | 45 | To perform an async call: 46 | ```ruby 47 | require 'net-http2' 48 | 49 | # create a client 50 | client = NetHttp2::Client.new("http://nghttp2.org") 51 | 52 | # prepare request 53 | request = client.prepare_request(:get, '/') 54 | request.on(:headers) { |headers| p headers } 55 | request.on(:body_chunk) { |chunk| p chunk } 56 | request.on(:close) { puts "request completed!" } 57 | 58 | # send 59 | client.call_async(request) 60 | 61 | # Wait for all outgoing stream to be closed 62 | client.join 63 | 64 | # close the connection 65 | client.close 66 | ``` 67 | 68 | ## Objects 69 | 70 | ### `NetHttp2::Client` 71 | 72 | #### Methods 73 | 74 | * **new(url, options={})** → **`NetHttp2::Client`** 75 | 76 | Returns a new client. `url` is a `string` such as `http://nghttp2.org`. 77 | The current options are: 78 | 79 | * `:connect_timeout`, specifies the max connect timeout in seconds (defaults to 60). 80 | * `:ssl_context`, in case the url has an https scheme and you want your SSL client to use a custom context. 81 | * `:proxy_addr`, `:proxy_port`, `:proxy_user`, `:proxy_pass`, specify Proxy connection parameters. 82 | 83 | To create a new client: 84 | ```ruby 85 | NetHttp2::Client.new("http://nghttp2.org") 86 | ``` 87 | 88 | To create a new client with a custom SSL context: 89 | ```ruby 90 | certificate = File.read("cert.pem") 91 | ctx = OpenSSL::SSL::SSLContext.new 92 | ctx.key = OpenSSL::PKey::RSA.new(certificate, "cert_password") 93 | ctx.cert = OpenSSL::X509::Certificate.new(certificate) 94 | 95 | NetHttp2::Client.new("https://nghttp2.org", ssl_context: ctx) 96 | ``` 97 | 98 | * **on(event, &block)** 99 | 100 | Allows to set a callback for the client. The only available event is `:error`, which allows to set a callback when an error is raised at socket level, hence in the underlying socket thread. 101 | 102 | ```ruby 103 | client.on(:error) { |exception| puts "Exception has been raised: #{exception}" } 104 | ``` 105 | 106 | > It is RECOMMENDED to set the `:error` callback: if none is defined, the underlying socket thread may raise an error in the main thread at unexpected execution times. 107 | 108 | * **uri** → **`URI`** 109 | 110 | Returns the URI of the endpoint. 111 | 112 | ##### Blocking calls 113 | These behave similarly to HTTP/1 calls. 114 | 115 | * **call(method, path, options={})** → **`NetHttp2::Response` or `nil`** 116 | 117 | Sends a request. Returns `nil` in case a timeout occurs. 118 | 119 | `method` is a symbol that specifies the `:method` header (`:get`, `:post`, `:put`, `:patch`, `:delete`, `:options`). The body, headers and query-string params of the request can be specified in the options, together with the timeout. 120 | 121 | ```ruby 122 | response_1 = client.call(:get, '/path1') 123 | response_2 = client.call(:get, '/path2', headers: { 'x-custom' => 'custom' }) 124 | response_3 = client.call(:post, '/path3', body: "the request body", timeout: 1) 125 | response_3 = client.call(:post, '/path4', params: { page: 4 }) 126 | ``` 127 | 128 | ##### Non-blocking calls 129 | The real benefit of HTTP/2 is being able to receive body and header streams. Instead of buffering the whole response, you might want to react immediately upon receiving those streams. This is what non-blocking calls are for. 130 | 131 | * **prepare_request(method, path, options={})** → **`NetHttp2::Request`** 132 | 133 | Prepares an async request. Arguments are the same as the `call` method, with the difference that the `:timeout` option will be ignored. In an async call, you will need to write your own logic for timeouts. 134 | 135 | ```ruby 136 | request = client.prepare_request(:get, '/path', headers: { 'x-custom-header' => 'custom' }) 137 | ``` 138 | 139 | * **call_async(request)** 140 | 141 | Calls the server with the async request. 142 | 143 | * **join** 144 | 145 | Wait for all outstanding requests to be completed. 146 | 147 | 148 | ### `NetHttp2::Request` 149 | 150 | #### Methods 151 | 152 | * **method** → **`symbol`** 153 | 154 | The request's method. 155 | 156 | * **uri** → **`URI`** 157 | 158 | The request's URI. 159 | 160 | * **path** → **`string`** 161 | 162 | The request's path. 163 | 164 | * **params** → **`hash`** 165 | 166 | The query string params in hash format, for example `{one: 1, two: 2}`. These will be encoded and appended to `path`. 167 | 168 | * **body** → **`string`** 169 | 170 | The request's body. 171 | 172 | * **timeout** → **`integer`** 173 | 174 | The request's timeout. 175 | 176 | * **on(event, &block)** 177 | 178 | Allows to set a callback for the request. Available events are: 179 | 180 | * `:headers`: triggered when headers are received (called once). 181 | * `:body_chunk`: triggered when body chunks are received (may be called multiple times). 182 | * `:close`: triggered when the request has been completed (called once). 183 | 184 | Even if NetHttp2 is thread-safe, the async callbacks will be executed in a different thread, so ensure that your code in the callbacks is thread-safe. 185 | 186 | ```ruby 187 | request.on(:headers) { |headers| p headers } 188 | request.on(:body_chunk) { |chunk| p chunk } 189 | request.on(:close) { puts "request completed!" } 190 | ``` 191 | 192 | 193 | ### `NetHttp2::Response` 194 | 195 | #### Methods 196 | 197 | * **ok?** → **`boolean`** 198 | 199 | Returns if the request was successful. 200 | 201 | * **headers** → **`hash`** 202 | 203 | Returns a Hash containing the Headers of the response. 204 | 205 | * **status** → **`string`** 206 | 207 | Returns the status code. 208 | 209 | * **body** → **`string`** 210 | 211 | Returns the RAW body of the response. 212 | 213 | 214 | ## Thread-Safety 215 | NetHttp2 is thread-safe. However, some caution is imperative: 216 | 217 | * The async callbacks will be executed in a different thread, so ensure that your code in the callbacks is thread-safe. 218 | * Errors in the underlying socket loop thread will be raised in the main thread at unexpected execution times, unless you specify the `:error` callback on the Client (recommended). 219 | 220 | ## Contributing 221 | So you want to contribute? That's great! Please follow the guidelines below. It will make it easier to get merged in. 222 | 223 | Before implementing a new feature, please submit a ticket to discuss what you intend to do. Your feature might already be in the works, or an alternative implementation might have already been discussed. 224 | 225 | Do not commit to master in your fork. Provide a clean branch without merge commits. Every pull request should have its own topic branch. In this way, every additional adjustments to the original pull request might be done easily, and squashed with `git rebase -i`. The updated branch will be visible in the same pull request, so there will be no need to open new pull requests when there are changes to be applied. 226 | 227 | Ensure to include proper testing. To run tests you simply have to be in the project's root directory and run: 228 | 229 | ```bash 230 | $ rake 231 | ``` 232 | -------------------------------------------------------------------------------- /spec/api/sending_async_requests_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Sending async requests" do 4 | let(:port) { 9516 } 5 | let(:server) { NetHttp2::Dummy::Server.new(port: port) } 6 | let(:client) { NetHttp2::Client.new("http://localhost:#{port}") } 7 | 8 | before { server.listen } 9 | 10 | after do 11 | client.close 12 | server.stop 13 | end 14 | 15 | it "sends an async request without a body" do 16 | incoming_request = nil 17 | server.on_req = Proc.new do |req, stream| 18 | incoming_request = req 19 | 20 | body_chunk_1 = "response body" 21 | body_chunk_2 = " and another chunk" 22 | 23 | stream.headers({ 24 | ':status' => "200", 25 | 'content-length' => (body_chunk_1 + body_chunk_2).bytesize.to_s 26 | }, end_stream: false) 27 | 28 | stream.data(body_chunk_1, end_stream: false) 29 | stream.data(body_chunk_2, end_stream: true) 30 | end 31 | 32 | request = client.prepare_request(:get, '/path', headers: { 'x-custom-header' => 'custom' }) 33 | 34 | headers = nil 35 | body = '' 36 | completed = false 37 | request.on(:headers) { |hs| headers = hs } 38 | request.on(:body_chunk) { |chunk| body << chunk } 39 | request.on(:close) { completed = true } 40 | 41 | expect(request).to be_a NetHttp2::Request 42 | 43 | client.call_async(request) 44 | client.join 45 | 46 | expect(headers).to_not be_nil 47 | expect(body).to_not eq '' 48 | 49 | expect(headers[':status']).to eq "200" 50 | expect(headers['content-length']).to eq "31" 51 | expect(body).to eq "response body and another chunk" 52 | expect(completed).to eq true 53 | 54 | expect(incoming_request).not_to be_nil 55 | expect(incoming_request.headers[":scheme"]).to eq "http" 56 | expect(incoming_request.headers[":method"]).to eq "GET" 57 | expect(incoming_request.headers[":path"]).to eq "/path" 58 | expect(incoming_request.headers[":authority"]).to eq "localhost:#{port}" 59 | expect(incoming_request.headers["x-custom-header"]).to eq "custom" 60 | end 61 | 62 | it "sends an async request with a body" do 63 | incoming_request = nil 64 | server.on_req = Proc.new do |req, stream| 65 | incoming_request = req 66 | 67 | body_chunk_1 = "response body" 68 | body_chunk_2 = " and another chunk" 69 | 70 | stream.headers({ 71 | ':status' => "200", 72 | 'content-length' => (body_chunk_1 + body_chunk_2).bytesize.to_s 73 | }, end_stream: false) 74 | 75 | stream.data(body_chunk_1, end_stream: false) 76 | stream.data(body_chunk_2, end_stream: true) 77 | end 78 | 79 | request = client.prepare_request(:get, '/path', 80 | headers: { 'x-custom-header' => 'custom' }, 81 | body: "request body" 82 | ) 83 | 84 | headers = nil 85 | body = '' 86 | completed = false 87 | request.on(:headers) { |hs| headers = hs } 88 | request.on(:body_chunk) { |chunk| body << chunk } 89 | request.on(:close) { completed = true } 90 | 91 | expect(request).to be_a NetHttp2::Request 92 | 93 | client.call_async(request) 94 | client.join 95 | 96 | expect(headers).to_not be_nil 97 | expect(headers[':status']).to eq "200" 98 | expect(headers['content-length']).to eq "31" 99 | 100 | expect(body).to eq "response body and another chunk" 101 | expect(completed).to eq true 102 | 103 | expect(incoming_request).not_to be_nil 104 | expect(incoming_request.headers[":scheme"]).to eq "http" 105 | expect(incoming_request.headers[":method"]).to eq "GET" 106 | expect(incoming_request.headers[":path"]).to eq "/path" 107 | expect(incoming_request.headers[":authority"]).to eq "localhost:#{port}" 108 | expect(incoming_request.headers["x-custom-header"]).to eq "custom" 109 | expect(incoming_request.body).to eq "request body" 110 | end 111 | 112 | it "sends multiple async requests sequentially" do 113 | server.on_req = Proc.new do |req, stream| 114 | body_chunk_1 = "response body for #{req.headers[':path']}" 115 | body_chunk_2 = " and another chunk" 116 | 117 | stream.headers({ 118 | ':status' => "200", 119 | 'content-length' => (body_chunk_1 + body_chunk_2).bytesize.to_s 120 | }, end_stream: false) 121 | 122 | stream.data(body_chunk_1, end_stream: false) 123 | stream.data(body_chunk_2, end_stream: true) 124 | end 125 | 126 | request_1 = client.prepare_request(:get, '/path1') 127 | expect(request_1).to be_a NetHttp2::Request 128 | 129 | headers_1 = nil 130 | body_1 = '' 131 | completed_1 = false 132 | request_1.on(:headers) { |hs| headers_1 = hs } 133 | request_1.on(:body_chunk) { |chunk| body_1 << chunk } 134 | request_1.on(:close) { completed_1 = true } 135 | 136 | request_2 = client.prepare_request(:get, '/path2') 137 | expect(request_2).to be_a NetHttp2::Request 138 | 139 | headers_2 = nil 140 | body_2 = '' 141 | completed_2 = false 142 | request_2.on(:headers) { |hs| headers_2 = hs } 143 | request_2.on(:body_chunk) { |chunk| body_2 << chunk } 144 | request_2.on(:close) { completed_2 = true } 145 | 146 | client.call_async(request_1) 147 | client.call_async(request_2) 148 | client.join 149 | 150 | expect(headers_1).to_not be_nil 151 | expect(headers_1[':status']).to eq "200" 152 | expect(headers_1['content-length']).to eq "42" 153 | 154 | expect(headers_2).to_not be_nil 155 | expect(headers_2[':status']).to eq "200" 156 | expect(headers_2['content-length']).to eq "42" 157 | 158 | expect(body_1).to eq "response body for /path1 and another chunk" 159 | expect(body_2).to eq "response body for /path2 and another chunk" 160 | 161 | expect(completed_1).to eq true 162 | expect(completed_2).to eq true 163 | end 164 | 165 | it "sends multiple async requests concurrently" do 166 | server.on_req = Proc.new do |req, stream| 167 | body_chunk_1 = "response body for #{req.headers[':path']}" 168 | body_chunk_2 = " and another chunk" 169 | 170 | stream.headers({ 171 | ':status' => "200", 172 | 'content-length' => (body_chunk_1 + body_chunk_2).bytesize.to_s 173 | }, end_stream: false) 174 | 175 | stream.data(body_chunk_1, end_stream: false) 176 | stream.data(body_chunk_2, end_stream: true) 177 | end 178 | 179 | request_1 = client.prepare_request(:get, '/path1') 180 | expect(request_1).to be_a NetHttp2::Request 181 | 182 | headers_1 = nil 183 | body_1 = '' 184 | completed_1 = false 185 | request_1.on(:headers) { |hs| headers_1 = hs } 186 | request_1.on(:body_chunk) { |chunk| body_1 << chunk } 187 | request_1.on(:close) { completed_1 = true } 188 | 189 | request_2 = client.prepare_request(:get, '/path2') 190 | expect(request_2).to be_a NetHttp2::Request 191 | 192 | headers_2 = nil 193 | body_2 = '' 194 | completed_2 = false 195 | request_2.on(:headers) { |hs| headers_2 = hs } 196 | request_2.on(:body_chunk) { |chunk| body_2 << chunk } 197 | request_2.on(:close) { completed_2 = true } 198 | 199 | client.call_async(request_1) 200 | thread = Thread.new { client.call_async(request_2) } 201 | thread.join 202 | 203 | client.join 204 | 205 | expect(headers_1).to_not be_nil 206 | expect(headers_1[':status']).to eq "200" 207 | expect(headers_1['content-length']).to eq "42" 208 | 209 | expect(headers_2).to_not be_nil 210 | expect(headers_2[':status']).to eq "200" 211 | expect(headers_2['content-length']).to eq "42" 212 | 213 | expect(body_1).to eq "response body for /path1 and another chunk" 214 | expect(body_2).to eq "response body for /path2 and another chunk" 215 | 216 | expect(completed_1).to eq true 217 | expect(completed_2).to eq true 218 | end 219 | 220 | it "sends an async request without a body and receives big bodies" do 221 | big_body = "a" * 100_000 222 | 223 | server.on_req = Proc.new do |_req| 224 | NetHttp2::Response.new( 225 | headers: { ":status" => "200" }, 226 | body: big_body.dup 227 | ) 228 | end 229 | 230 | request = client.prepare_request(:get, '/path') 231 | 232 | headers = nil 233 | body = '' 234 | completed = false 235 | request.on(:headers) { |hs| headers = hs } 236 | request.on(:body_chunk) { |chunk| body << chunk } 237 | request.on(:close) { completed = true } 238 | 239 | expect(request).to be_a NetHttp2::Request 240 | 241 | client.call_async(request) 242 | client.join 243 | 244 | expect(headers).to_not be_nil 245 | expect(headers[':status']).to eq "200" 246 | expect(headers['content-length']).to eq "100000" 247 | 248 | expect(body).to eq big_body 249 | expect(completed).to eq true 250 | end 251 | 252 | it "sends an async POST requests with big bodies" do 253 | big_body = "a" * 100_000 254 | 255 | received_body = nil 256 | server.on_req = Proc.new do |req| 257 | received_body = req.body 258 | 259 | NetHttp2::Response.new( 260 | headers: { ":status" => "200" }, 261 | body: "response body" 262 | ) 263 | end 264 | 265 | request = client.prepare_request(:post, '/path', body: big_body.dup) 266 | 267 | headers = nil 268 | body = '' 269 | completed = false 270 | request.on(:headers) { |hs| headers = hs } 271 | request.on(:body_chunk) { |chunk| body << chunk } 272 | request.on(:close) { completed = true } 273 | 274 | expect(request).to be_a NetHttp2::Request 275 | 276 | client.call_async(request) 277 | client.join 278 | 279 | expect(received_body).to eq big_body 280 | 281 | expect(headers).to_not be_nil 282 | expect(headers[':status']).to eq "200" 283 | expect(headers['content-length']).to eq "13" 284 | 285 | expect(body).to eq "response body" 286 | expect(completed).to eq true 287 | end 288 | end 289 | -------------------------------------------------------------------------------- /spec/api/errors_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Errors" do 4 | let(:port) { 9516 } 5 | let(:server) { NetHttp2::Dummy::Server.new(port: port, ssl: true) } 6 | let(:client) { NetHttp2::Client.new("https://localhost:#{port}") } 7 | 8 | describe "Sync calls" do 9 | describe "Connection errors" do 10 | 11 | context "when :error callback is not defined" do 12 | 13 | it "raise errors when server cannot be reached" do 14 | expect { client.call(:get, '/path') }.to raise_error Errno::ECONNREFUSED 15 | end 16 | end 17 | 18 | context "when :error callback is defined" do 19 | 20 | before do 21 | client.on(:error) { |_exc| nil } 22 | end 23 | 24 | it "raise errors when server cannot be reached" do 25 | expect { client.call(:get, '/path') }.to raise_error Errno::ECONNREFUSED 26 | end 27 | end 28 | end 29 | 30 | describe "EOFErrors on socket" do 31 | 32 | before { server.listen } 33 | after do 34 | client.close 35 | server.stop 36 | end 37 | 38 | context "when :error callback is not defined" do 39 | 40 | it "raises a SocketError" do 41 | server.on_req = Proc.new do |_req, _stream, socket| 42 | socket.close 43 | end 44 | 45 | expect { client.call(:get, '/path') }.to raise_error SocketError, 'Socket was remotely closed' 46 | end 47 | 48 | it "repairs the connection for subsequent calls" do 49 | close_next_socket = true 50 | server.on_req = Proc.new do |_req, _stream, socket| 51 | if close_next_socket 52 | close_next_socket = false 53 | socket.close 54 | else 55 | NetHttp2::Response.new( 56 | headers: { ":status" => "200" }, 57 | body: "response body" 58 | ) 59 | end 60 | end 61 | 62 | client.call(:get, '/path') rescue SocketError 63 | 64 | response = client.call(:get, '/path') 65 | expect(response.status).to eq '200' 66 | expect(response.body).to eq 'response body' 67 | end 68 | end 69 | 70 | context "when :error callback is defined" do 71 | 72 | before do 73 | @exception = nil 74 | client.on(:error) do |exc| 75 | @exception = exc 76 | end 77 | end 78 | 79 | it "calls the :error callback" do 80 | server.on_req = Proc.new do |_req, _stream, socket| 81 | socket.close 82 | end 83 | 84 | request = client.prepare_request(:get, '/path') 85 | 86 | client.call_async(request) 87 | client.join 88 | 89 | expect(@exception).to be_a SocketError 90 | expect(@exception.message).to eq 'Socket was remotely closed' 91 | end 92 | 93 | it "repairs the connection for subsequent calls" do 94 | close_next_socket = true 95 | server.on_req = Proc.new do |_req, _stream, socket| 96 | if close_next_socket 97 | close_next_socket = false 98 | socket.close 99 | else 100 | NetHttp2::Response.new( 101 | headers: { ":status" => "200" }, 102 | body: "response body" 103 | ) 104 | end 105 | end 106 | 107 | request = client.prepare_request(:get, '/path') 108 | client.call_async(request) 109 | client.join 110 | 111 | headers = nil 112 | body = '' 113 | completed = false 114 | request = client.prepare_request(:get, '/path') 115 | request.on(:headers) { |hs| headers = hs } 116 | request.on(:body_chunk) { |chunk| body << chunk } 117 | request.on(:close) { completed = true } 118 | 119 | client.call_async(request) 120 | client.join 121 | 122 | expect(headers).to_not be_nil 123 | expect(headers[':status']).to eq "200" 124 | expect(headers['content-length']).to eq "13" 125 | 126 | expect(body).to eq "response body" 127 | 128 | expect(completed).to eq true 129 | end 130 | end 131 | end 132 | end 133 | 134 | describe "Async calls" do 135 | 136 | describe "Connection errors" do 137 | 138 | context "when :error callback is not defined" do 139 | 140 | it "raise errors when server cannot be reached" do 141 | request = client.prepare_request(:get, '/path') 142 | 143 | expect { client.call_async(request) }.to raise_error Errno::ECONNREFUSED 144 | end 145 | end 146 | 147 | context "when :error callback is defined" do 148 | 149 | before do 150 | client.on(:error) { |_exc| nil } 151 | end 152 | 153 | it "raise errors when server cannot be reached" do 154 | request = client.prepare_request(:get, '/path') 155 | 156 | expect { client.call_async(request) }.to raise_error Errno::ECONNREFUSED 157 | end 158 | end 159 | end 160 | 161 | describe "EOFErrors on socket" do 162 | 163 | before { server.listen } 164 | after do 165 | client.close 166 | server.stop 167 | end 168 | 169 | context "when :error callback is defined" do 170 | 171 | before do 172 | @exception = nil 173 | client.on(:error) do |exc| 174 | @exception = exc 175 | end 176 | end 177 | 178 | it "calls the :error callback" do 179 | server.on_req = Proc.new do |_req, _stream, socket| 180 | socket.close 181 | end 182 | 183 | request = client.prepare_request(:get, '/path') 184 | 185 | client.call_async(request) 186 | client.join 187 | 188 | expect(@exception).to be_a SocketError 189 | expect(@exception.message).to eq 'Socket was remotely closed' 190 | end 191 | 192 | it "repairs the connection for subsequent calls" do 193 | close_next_socket = true 194 | server.on_req = Proc.new do |_req, _stream, socket| 195 | if close_next_socket 196 | close_next_socket = false 197 | socket.close 198 | else 199 | NetHttp2::Response.new( 200 | headers: { ":status" => "200" }, 201 | body: "response body" 202 | ) 203 | end 204 | end 205 | 206 | request = client.prepare_request(:get, '/path') 207 | client.call_async(request) 208 | client.join 209 | 210 | headers = nil 211 | body = '' 212 | completed = false 213 | request = client.prepare_request(:get, '/path') 214 | request.on(:headers) { |hs| headers = hs } 215 | request.on(:body_chunk) { |chunk| body << chunk } 216 | request.on(:close) { completed = true } 217 | 218 | client.call_async(request) 219 | client.join 220 | 221 | expect(headers).to_not be_nil 222 | expect(headers[':status']).to eq "200" 223 | expect(headers['content-length']).to eq "13" 224 | 225 | expect(body).to eq "response body" 226 | 227 | expect(completed).to eq true 228 | end 229 | end 230 | 231 | context "when :error callback is not defined" do 232 | 233 | it "raises a SocketError in main thread" do 234 | server.on_req = Proc.new do |_req, _stream, socket| 235 | socket.close 236 | end 237 | 238 | request = client.prepare_request(:get, '/path') 239 | 240 | event_triggered = false 241 | request.on(:headers) do |_hs| 242 | event_triggered = true 243 | raise "error while processing event #{event}" 244 | end 245 | 246 | client.call_async(request) 247 | 248 | expect { wait_for { event_triggered } }.to raise_error SocketError, 'Socket was remotely closed' 249 | end 250 | 251 | it "repairs the connection for subsequent calls" do 252 | close_next_socket = true 253 | server.on_req = Proc.new do |_req, _stream, socket| 254 | if close_next_socket 255 | close_next_socket = false 256 | socket.close 257 | else 258 | NetHttp2::Response.new( 259 | headers: { ":status" => "200" }, 260 | body: "response body" 261 | ) 262 | end 263 | end 264 | 265 | request = client.prepare_request(:get, '/path') 266 | 267 | event_triggered = false 268 | request.on(:headers) do |_hs| 269 | event_triggered = true 270 | raise "error while processing event #{event}" 271 | end 272 | 273 | client.call_async(request) 274 | wait_for { event_triggered } rescue SocketError 275 | 276 | headers = nil 277 | body = '' 278 | completed = false 279 | request = client.prepare_request(:get, '/path') 280 | request.on(:headers) { |hs| headers = hs } 281 | request.on(:body_chunk) { |chunk| body << chunk } 282 | request.on(:close) { completed = true } 283 | 284 | client.call_async(request) 285 | client.join 286 | 287 | expect(headers).to_not be_nil 288 | expect(headers[':status']).to eq "200" 289 | expect(headers['content-length']).to eq "13" 290 | 291 | expect(body).to eq "response body" 292 | 293 | expect(completed).to eq true 294 | end 295 | end 296 | end 297 | 298 | describe "Errors in callbacks" do 299 | 300 | before { server.listen } 301 | after do 302 | client.close 303 | server.stop 304 | end 305 | 306 | [ 307 | :headers, 308 | :body_chunk, 309 | # :close TODO: remove this 310 | ].each do |event| 311 | 312 | it "does not silently fail if errors are raised in the #{event} event" do 313 | request = client.prepare_request(:get, '/path') 314 | 315 | event_triggered = false 316 | request.on(event) do |_hs| 317 | event_triggered = true 318 | raise "error while processing event :#{event}" 319 | end 320 | 321 | client.call_async(request) 322 | 323 | expect { wait_for { event_triggered } }.to raise_error "error while processing event :#{event}" 324 | end 325 | end 326 | end 327 | end 328 | end 329 | --------------------------------------------------------------------------------