mirror of
https://github.com/HoneyryderChuck/httpx.git
synced 2025-08-10 00:01:27 -04:00
added first structure draft, resource names to change, but... it works\! to nghttp2
This commit is contained in:
parent
63ce9297c8
commit
0bcfc7fbe2
7
examples/get.rb
Normal file
7
examples/get.rb
Normal 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
15
lib/httpx.rb
Normal 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
27
lib/httpx/callbacks.rb
Normal 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
20
lib/httpx/client.rb
Normal 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
65
lib/httpx/connection.rb
Normal 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
|
110
lib/httpx/connection/http2.rb
Normal file
110
lib/httpx/connection/http2.rb
Normal 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
131
lib/httpx/headers.rb
Normal 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
31
lib/httpx/request.rb
Normal 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
24
lib/httpx/response.rb
Normal 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
18
lib/httpx/scheme.rb
Normal 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
114
lib/httpx/scheme/http.rb
Normal 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
5
lib/httpx/version.rb
Normal file
@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTTPX
|
||||
VERSION = "0.0.0"
|
||||
end
|
Loading…
x
Reference in New Issue
Block a user