added first structure draft, resource names to change, but... it works\! to nghttp2

This commit is contained in:
HoneyryderChuck 2017-11-28 01:24:24 +00:00
parent 63ce9297c8
commit 0bcfc7fbe2
12 changed files with 567 additions and 0 deletions

7
examples/get.rb Normal file
View File

@ -0,0 +1,7 @@
require "httpx"
client = HTTPX::Client.new
request = client.request(:get, "http://nghttp2.org")
response = client.send(request)
puts response.to_s

15
lib/httpx.rb Normal file
View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
require "httpx/version"
require "httpx/callbacks"
require "httpx/scheme"
require "httpx/connection"
require "httpx/headers"
require "httpx/request"
require "httpx/response"
require "httpx/client"
module HTTPX
end

27
lib/httpx/callbacks.rb Normal file
View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
module HTTPX
module Callbacks
def on(type, &action)
callbacks(type) << action
end
def once(event, &block)
on(event) do |*args, &callback|
block.call(*args, &callback)
:delete
end
end
def emit(type, *args)
callbacks(type).delete_if { |pr| pr[*args] == :delete }
end
private
def callbacks(type)
@callbacks ||= Hash.new { |h, k| h[k] = [] }
@callbacks[type]
end
end
end

20
lib/httpx/client.rb Normal file
View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
module HTTPX
class Client
def initialize(**options)
@connection = Connection.new(**options)
@default_options = options
end
def request(verb, uri, **options)
request = Request.new(verb, uri, **@default_options.merge(options))
end
def send(request)
@connection << request
@connection.process_events until response = @connection.response(request)
response
end
end
end

65
lib/httpx/connection.rb Normal file
View File

@ -0,0 +1,65 @@
# frozen_string_literal: true
require "socket"
require "timeout"
module HTTPX
class Connection
require "httpx/connection/http2"
PROTOCOLS = {
"h2" => HTTP2
}
CONNECTION_TIMEOUT = 2
def initialize(**options)
@options = options
@connection_timeout = options.fetch(:connection_timeout, CONNECTION_TIMEOUT)
@channels = {}
@responses = {}
end
def bind(uri)
uri = URI(uri)
ip = TCPSocket.getaddress(uri.host)
return @channels.values.find do |io|
ip == io.remote_ip && uri.port == io.remote_port
end || begin
scheme = Scheme.by(uri)
@channels[scheme.to_io] = scheme
end
end
def <<(request)
channel = bind(request.uri)
raise "no channel available" unless channel
channel.processor ||= begin
pr = PROTOCOLS[channel.protocol].new
pr.on(:response) do |request, response|
@responses[request] = response
end
pr
end
channel.send(request)
end
def response(request)
@responses[request]
end
def process_events(timeout: @connection_timeout)
rmonitors = @channels.values
wmonitors = rmonitors.reject(&:empty?)
readers, writers = IO.select(rmonitors, wmonitors, nil, timeout)
raise Timeout::Error, "timed out waiting for data" if readers.nil? && writers.nil?
readers.each do |reader|
reader.dread
end if readers
writers.each do |writer|
writer.dwrite
end if writers
end
end
end

View File

@ -0,0 +1,110 @@
# frozen_string_literal: true
require "http/2"
module HTTPX
class Connection::HTTP2
include Callbacks
attr_accessor :buffer
def initialize
@connection = HTTP2::Client.new
@connection.on(:frame, &method(:on_frame))
@connection.on(:frame_sent, &method(:on_frame_sent))
@connection.on(:frame_received, &method(:on_frame_received))
@connection.on(:promise, &method(:on_promise))
@connection.on(:altsvc, &method(:on_altsvc))
@streams = {}
end
def empty?
@buffer.empty?
end
def <<(data)
@connection << data
end
def send(request)
uri = request.uri
stream = @connection.new_stream
stream.on(:close) do
emit(:response, request, @streams.delete(stream))
end
# stream.on(:half_close)
# stream.on(:altsvc)
stream.on(:headers) do |headers|
_, status = headers.shift
@streams[stream] = Response.new(status, headers)
end
stream.on(:data) do |data|
@streams[stream] << data
end
headers = {}
headers[":scheme"] = uri.scheme
headers[":method"] = request.verb.to_s.upcase
headers[":path"] = request.path
headers[":authority"] = request.authority
headers = headers.merge(request.headers)
if body = request.body
headers["content-length"] = String(body.bytesize) if body.respond_to?(:bytesize)
# TODO: expect-continue
stream.data(headers, end_stream: false)
stream.data(body.to_s, end_stream: true)
else
stream.headers(headers, end_stream: true)
end
end
private
######
# HTTP/2 Callbacks
######
def on_frame(bytes)
@buffer << bytes
end
def on_frame_sent(frame)
log { "frame was sent!" }
log do
case frame[:type]
when :data
frame.merge(payload: frame[:payload].bytesize).inspect
when :headers
"\e[33m#{frame.inspect}\e[0m"
else
frame.inspect
end
end
end
def on_frame_received(frame)
log { "frame was received" }
log do
case frame[:type]
when :data
frame.merge(payload: frame[:payload].bytesize).inspect
else
frame.inspect
end
end
end
def on_altsvc(frame)
log { "altsvc frame was received" }
log { frame.inspect }
end
def on_promise(stream)
end
def log(&msg)
$stderr << (+"connection (HTTP/2): " << msg.call << "\n")
end
end
end

131
lib/httpx/headers.rb Normal file
View File

@ -0,0 +1,131 @@
# frozen_string_literal: true
module HTTPX
class Headers
EMPTY = [].freeze # :nodoc:
def initialize(h = nil)
@headers = {}
return unless h
h.each do |field, value|
@headers[downcased(field)] = value
end
end
# cloned initialization
def initialize_clone(orig)
super
@headers = orig.instance_variable_get(:@headers).clone
end
# dupped initialization
def initialize_dup(orig)
super
@headers = orig.instance_variable_get(:@headers).dup
end
# freezes the headers hash
def freeze
@headers.freeze
super
end
# merges headers with another header-quack.
# the merge rule is, if the header already exists,
# ignore what the +other+ headers has. Otherwise, set
#
def merge(other)
# TODO: deep-copy
headers = dup
other.each do |field, value|
headers[field] = value
end
headers
end
# returns the comma-separated values of the header field
# identified by +field+, or nil otherwise.
#
def [](field)
a = @headers[downcased(field)] || return
a.join(",")
end
# sets +value+ (if not nil) as single value for the +field+ header.
#
def []=(field, value)
return unless value
@headers[downcased(field)] = [String(value)]
end
# deletes all values associated with +field+ header.
#
def delete(field)
canonical = downcased(field)
@headers.delete(canonical) if @headers.key?(canonical)
end
# adds additional +value+ to the existing, for header +field+.
#
def add(field, value)
(@headers[downcased(field)] ||= []) << String(value)
end
# helper to be used when adding an header field as a value to another field
#
# h2_headers.add_header("vary", "accept-encoding")
# h2_headers["vary"] #=> "accept-encoding"
# h1_headers.add_header("vary", "accept-encoding")
# h1_headers["vary"] #=> "Accept-Encoding"
#
alias_method :add_header, :add
# returns the enumerable headers store in pairs of header field + the values in
# the comma-separated string format
#
def each
return enum_for(__method__) { @headers.size } unless block_given?
@headers.each do |field, value|
yield(field, value.join(",")) unless value.empty?
end
end
# the headers store in Hash format
def to_hash
Hash[to_a]
end
# the headers store in array of pairs format
def to_a
Array(each)
end
# headers as string
def to_s
@headers.to_s
end
# this is internal API and doesn't abide to other public API
# guarantees, like downcasing strings.
# Please do not use this outside of core!
#
def key?(downcased_key)
@headers.key?(downcased_key)
end
# returns the values for the +field+ header in array format.
# This method is more internal, and for this reason doesn't try
# to "correct" the user input, i.e. it doesn't downcase the key.
#
def get(field)
@headers[field] || EMPTY
end
private
def downcased(field)
field.downcase
end
end
end

31
lib/httpx/request.rb Normal file
View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
module HTTPX
class Request
attr_reader :verb, :uri, :headers, :body
def initialize(verb, uri, headers: {}, **options)
@verb = verb.to_s.downcase.to_sym
@uri = URI(uri)
@headers = Headers.new(headers)
@body = nil
end
def path
path = uri.path
path << "/" if path.empty?
path << "?#{uri.query}" if uri.query
path
end
def <<(data)
(@body ||= +"") << data
end
def authority
host = @uri.host
port_string = @uri.port == @uri.default_port ? nil : ":#{@uri.port}"
"#{host}#{port_string}"
end
end
end

24
lib/httpx/response.rb Normal file
View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
require "forwardable"
module HTTPX
class Response
extend Forwardable
attr_reader :status, :headers, :body
def_delegator :@body, :to_s
def initialize(status, headers)
@status = Integer(status)
@headers = Headers.new(headers)
@body = +"".b
end
def <<(data)
@body << data
end
end
end

18
lib/httpx/scheme.rb Normal file
View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
module HTTPX::Scheme
module_function
def by(uri)
case uri.scheme
when "http"
HTTP.new(uri)
when "https"
HTTPS.new(uri)
else
raise "unrecognized Scheme"
end
end
end
require "httpx/scheme/http"

114
lib/httpx/scheme/http.rb Normal file
View File

@ -0,0 +1,114 @@
# frozen_string_literal: true
require "forwardable"
module HTTPX
class Scheme::HTTP
extend Forwardable
include Callbacks
BUFFER_SIZE = 1 << 16
attr_reader :processor
attr_reader :remote_ip, :remote_port
def_delegator :@io, :to_io
def_delegator :@processor, :empty?
def initialize(uri)
@io = TCPSocket.new(uri.host, uri.port)
_, @remote_port, _,@remote_ip = @io.peeraddr
@read_buffer = +""
@write_buffer = +""
end
def processor=(processor)
processor.buffer = @write_buffer
@processor = processor
end
def protocol
"h2"
end
def send(request)
@processor.send(request)
end
if RUBY_VERSION < "2.3"
def dread(size = BUFFER_SIZE)
begin
loop do
@io.read_nonblock(size, @read_buffer)
@processor << @read_buffer
end
rescue IO::WaitReadable
# wait read/write
rescue EOFError
# EOF
@io.close
end
end
def dwrite
begin
loop do
return if @write_buffer.empty?
siz = @io.write_nonblock(@write_buffer)
@write_buffer.slice!(0, siz)
end
rescue IO::WaitWritable
# wait read/write
rescue EOFError
# EOF
@io.close
end
end
else
def dread(size = BUFFER_SIZE)
loop do
buf = @io.read_nonblock(size, @read_buffer, exception: false)
case buf
when :wait_readable
return
when nil
@io.close
return
else
@processor << @read_buffer
end
end
end
def dwrite
loop do
return if @write_buffer.empty?
siz = @io.write_nonblock(@write_buffer, exception: false)
case siz
when :wait_writable
return
when nil
@io.close
return
else
@write_buffer.slice!(0, siz)
end
end
end
end
private
def perform_io
yield
rescue IO::WaitReadable, IO::WaitWritable
# wait read/write
rescue EOFError
# EOF
@io.close
end
end
end

5
lib/httpx/version.rb Normal file
View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
module HTTPX
VERSION = "0.0.0"
end