added main building blocks necessary to implement and verify the public API

This commit is contained in:
HoneyryderChuck 2017-11-27 16:54:02 +00:00
parent ef17b68274
commit b9091db9fa
9 changed files with 954 additions and 0 deletions

12
lib/httpx.rb Normal file
View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
require "httpx/version"
require "httpx/errors"
require "httpx/timeout/null"
require "httpx/options"
require "httpx/chainable"
module HTTPX
extend Chainable
end

193
lib/httpx/chainable.rb Normal file
View File

@ -0,0 +1,193 @@
# frozen_string_literal: true
require "base64"
module HTTPX
module Chainable
%i[head get post put delete trace options connect patch].each do |meth|
define_method meth do |uri, options = {}|
request meth, uri, options
end
end
# Make an HTTP request with the given verb
# @param uri
# @option options [Hash]
def request(verb, uri, options = {})
branch(options).request verb, uri
end
# @overload timeout(options = {})
# Syntax sugar for `timeout(:per_operation, options)`
# @overload timeout(klass, options = {})
# Adds a timeout to the request.
# @param [#to_sym] klass
# either :null, :global, or :per_operation
# @param [Hash] options
# @option options [Float] :read Read timeout
# @option options [Float] :write Write timeout
# @option options [Float] :connect Connect timeout
def timeout(klass, options = {}) # rubocop:disable Style/OptionHash
if klass.is_a? Hash
options = klass
klass = :per_operation
end
klass = case klass.to_sym
when :null then HTTPX::Timeout::Null
when :global then HTTPX::Timeout::Global
when :per_operation then HTTPX::Timeout::PerOperation
else raise ArgumentError, "Unsupported Timeout class: #{klass}"
end
%i[read write connect].each do |k|
next unless options.key? k
options["#{k}_timeout".to_sym] = options.delete k
end
branch default_options.merge(
:timeout_class => klass,
:timeout_options => options
)
end
# @overload persistent(host, timeout: 5)
# Flags as persistent
# @param [String] host
# @option [Integer] timeout Keep alive timeout
# @raise [Request::Error] if Host is invalid
# @return [HTTPX::Client] Persistent client
# @overload persistent(host, timeout: 5, &block)
# Executes given block with persistent client and automatically closes
# connection at the end of execution.
#
# @example
#
# def keys(users)
# HTTP.persistent("https://github.com") do |http|
# users.map { |u| http.get("/#{u}.keys").to_s }
# end
# end
#
# # same as
#
# def keys(users)
# http = HTTP.persistent "https://github.com"
# users.map { |u| http.get("/#{u}.keys").to_s }
# ensure
# http.close if http
# end
#
#
# @yieldparam [HTTPX::Client] client Persistent client
# @return [Object] result of last expression in the block
def persistent(host, timeout: 5)
options = {:keep_alive_timeout => timeout}
p_client = branch default_options.merge(options).with_persistent host
return p_client unless block_given?
yield p_client
ensure
p_client.close if p_client
end
# Make a request through an HTTP proxy
# @param [Array] proxy
# @raise [Request::Error] if HTTP proxy is invalid
def via(*proxy)
proxy_hash = {}
proxy_hash[:proxy_address] = proxy[0] if proxy[0].is_a?(String)
proxy_hash[:proxy_port] = proxy[1] if proxy[1].is_a?(Integer)
proxy_hash[:proxy_username] = proxy[2] if proxy[2].is_a?(String)
proxy_hash[:proxy_password] = proxy[3] if proxy[3].is_a?(String)
proxy_hash[:proxy_headers] = proxy[2] if proxy[2].is_a?(Hash)
proxy_hash[:proxy_headers] = proxy[4] if proxy[4].is_a?(Hash)
raise(RequestError, "invalid HTTP proxy: #{proxy_hash}") unless (2..5).cover?(proxy_hash.keys.size)
branch default_options.with_proxy(proxy_hash)
end
alias through via
# Make client follow redirects.
# @param opts
# @return [HTTPX::Client]
# @see Redirector#initialize
def follow(options = {}) # rubocop:disable Style/OptionHash
branch default_options.with_follow options
end
# Make a request with the given headers
# @param headers
def headers(headers)
branch default_options.with_headers(headers)
end
# Make a request with the given cookies
def cookies(cookies)
branch default_options.with_cookies(cookies)
end
# Force a specific encoding for response body
def encoding(encoding)
branch default_options.with_encoding(encoding)
end
# Accept the given MIME type(s)
# @param type
def accept(type)
headers Headers::ACCEPT => MimeType.normalize(type)
end
# Make a request with the given Authorization header
# @param [#to_s] value Authorization header value
def auth(value)
headers Headers::AUTHORIZATION => value.to_s
end
# Make a request with the given Basic authorization header
# @see http://tools.ietf.org/html/rfc2617
# @param [#fetch] opts
# @option opts [#to_s] :user
# @option opts [#to_s] :pass
def basic_auth(opts)
user = opts.fetch :user
pass = opts.fetch :pass
auth("Basic " + Base64.strict_encode64("#{user}:#{pass}"))
end
# Get options for HTTP
# @return [HTTPX::Options]
def default_options
@default_options ||= HTTPX::Options.new
end
# Set options for HTTP
# @param opts
# @return [HTTPX::Options]
def default_options=(opts)
@default_options = HTTPX::Options.new(opts)
end
# Set TCP_NODELAY on the socket
def nodelay
branch default_options.with_nodelay(true)
end
# Turn on given features. Available features are:
# * auto_inflate
# * auto_deflate
# @param features
def use(*features)
branch default_options.with_features(features)
end
private
# :nodoc:
def branch(options)
HTTPX::Client.new(options)
end
end
end

25
lib/httpx/errors.rb Normal file
View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
module HTTPX
# Generic error
class Error < StandardError; end
# Generic Connection error
class ConnectionError < Error; end
# Generic Request error
class RequestError < Error; end
# Generic Response error
class ResponseError < Error; end
# Requested to do something when we're in the wrong state
class StateError < ResponseError; end
# Generic Timeout error
class TimeoutError < Error; end
# Header value is of unexpected format (similar to Net::HTTPHeaderSyntaxError)
class HeaderError < Error; end
end

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

@ -0,0 +1,166 @@
# frozen_string_literal: true
module HTTPX
class Headers
EMPTY = [].freeze # :nodoc:
# Matches valid header field name according to RFC.
# @see http://tools.ietf.org/html/rfc7230#section-3.2
COMPLIANT_NAME_RE = /^[A-Za-z0-9!#\$%&'*+\-.^_`|~]+$/
def initialize(h = nil)
@headers = {}
return unless h
h.each do |field, value|
@headers[normalize(field)] = Array(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[normalize(field)] || return
a.join(",")
end
# sets +value+ (if not nil) as single value for the +field+ header.
#
def []=(field, value)
return unless value
val = case value
when Array
value.map { |f| String(f) }
else
[String(value)]
end
@headers[normalize(field)] = val
end
# deletes all values associated with +field+ header.
#
def delete(field)
canonical = normalize(field)
@headers.delete(canonical) if @headers.key?(canonical)
end
# adds additional +value+ to the existing, for header +field+.
#
def add(field, value)
@headers[normalize(field)] ||= []
val = case value
when Array
value.map { |f| String(f) }
else
[String(value)]
end
@headers[normalize(field)] += val
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|
next if value.empty?
value.each do |val|
yield(field, val)
end
end
self
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
def ==(other)
to_hash == self.class.new(other).to_hash
end
# this is internal API and doesn't abide to other public API
# guarantees, like normalizing strings.
# Please do not use this outside of core!
#
def key?(normalized_key)
@headers.key?(normalized_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 normalize the key.
#
def get(field)
@headers[normalize(field)] || EMPTY
end
private
# this method is only here because there's legacy around using explicit
# canonical header fields (ex: Content-Length), although the spec states that the header
# fields are case-insensitive.
#
# This only normalizes a +field+ if passed a canonical HTTP/1 header +field+.
#
def normalize(field)
normalized = String(field).downcase
return normalized if normalized =~ COMPLIANT_NAME_RE
raise HeaderError, "Invalid HTTP header field name: #{field.inspect}"
end
end
end

122
lib/httpx/options.rb Normal file
View File

@ -0,0 +1,122 @@
# frozen_string_literal: true
require "httpx/headers"
module HTTPX
class Options
@default_timeout_class = HTTPX::Timeout::Null
class << self
attr_accessor :default_timeout_class
def new(options = {})
return options if options.is_a?(self)
super
end
def defined_options
@defined_options ||= []
end
protected
def def_option(name, &interpreter)
defined_options << name.to_sym
interpreter ||= lambda { |v| v }
attr_accessor name
protected :"#{name}="
define_method(:"with_#{name}") do |value|
dup { |opts| opts.send(:"#{name}=", instance_exec(value, &interpreter)) }
end
end
end
def initialize(options = {}) # rubocop:disable Style/OptionHash
defaults = {
:proxy => {},
:timeout_class => self.class.default_timeout_class,
:timeout_options => {},
:ssl => {},
:keep_alive_timeout => 5,
:headers => {},
:cookies => {},
}
opts_w_defaults = defaults.merge(options)
opts_w_defaults[:headers] = HTTPX::Headers.new(opts_w_defaults[:headers])
opts_w_defaults.each { |(k, v)| self[k] = v }
end
def_option :headers do |headers|
self.headers.merge(headers)
end
def_option :cookies do |cookies|
cookies.each_with_object(self.cookies.dup) do |(k, v), jar|
cookie = k.is_a?(Cookie) ? k : Cookie.new(k.to_s, v.to_s)
jar[cookie.name] = cookie.cookie_value
end
end
%w[
proxy params form json body follow response
ssl_context ssl
keep_alive_timeout timeout_class timeout_options
].each do |method_name|
def_option method_name
end
def follow=(value)
@follow =
case
when !value then nil
when true == value then {}
when value.respond_to?(:fetch) then value
else argument_error! "Unsupported follow options: #{value}"
end
end
def merge(other)
h1 = to_hash
h2 = other.to_hash
merged = h1.merge(h2) do |k, v1, v2|
case k
when :headers
v1.merge(v2)
else
v2
end
end
self.class.new(merged)
end
def to_hash
hash_pairs = self.class.
defined_options.
flat_map { |opt_name| [opt_name, send(opt_name)] }
Hash[*hash_pairs]
end
def dup
dupped = super
yield(dupped) if block_given?
dupped
end
protected
def []=(option, val)
send(:"#{option}=", val)
end
private
def argument_error!(message)
raise(Error, message, caller(1..-1))
end
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
module HTTPX
module Timeout
class Null
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

247
spec/httpx/headers_spec.rb Normal file
View File

@ -0,0 +1,247 @@
# frozen_string_literal: true
RSpec.describe HTTPX::Headers do
subject(:headers) { described_class.new }
it "can become enumerable" do
expect(headers).to respond_to(:each)
end
describe "#[]=" do
it "sets header value" do
headers["accept"] = "application/json"
expect(headers["accept"]).to eq "application/json"
end
it "normalizes header name" do
headers["Content-Type"] = "application/json"
expect(headers["content-type"]).to eq "application/json"
end
it "overwrites previous value" do
headers["set-cookie"] = "hoo=ray"
headers["set-cookie"] = "woo=hoo"
expect(headers["set-cookie"]).to eq "woo=hoo"
end
it "allows set multiple values" do
headers["set-cookie"] = "hoo=ray"
headers["set-cookie"] = %w[hoo=ray woo=hoo]
expect(headers.get("set-cookie")).to eq %w[hoo=ray woo=hoo]
end
it "fails with empty header name" do
expect { headers[""] = "foo bar" }.
to raise_error HTTPX::HeaderError
end
it "fails with invalid header name" do
expect { headers["foo bar"] = "baz" }.
to raise_error HTTPX::HeaderError
end
end
describe "#delete" do
before { headers["content-type"] = "application/json" }
it "removes given header" do
headers.delete("content-type")
expect(headers["content-type"]).to be_nil
end
it "normalizes header name" do
headers.delete("Content-Type")
expect(headers["content-type"]).to be_nil
end
it "fails with empty header name" do
expect { headers.delete("") }.
to raise_error HTTPX::HeaderError
end
it "fails with invalid header name" do
expect { headers.delete("foo bar") }.
to raise_error HTTPX::HeaderError
end
end
describe "#add" do
it "sets header value" do
headers.add "Accept", "application/json"
expect(headers["accept"]).to eq "application/json"
end
it "normalizes header name" do
headers.add "Content-Type", "application/json"
expect(headers["content-type"]).to eq "application/json"
end
it "appends new value if header exists" do
headers.add "set-cookie", "hoo=ray"
headers.add "set-cookie", "woo=hoo"
expect(headers.get("set-cookie")).to eq %w[hoo=ray woo=hoo]
end
it "allows append multiple values" do
headers.add "set-cookie", "hoo=ray"
headers.add "set-cookie", %w[woo=hoo yup=pie]
expect(headers.get("set-cookie")).to eq %w[hoo=ray woo=hoo yup=pie]
end
it "fails with empty header name" do
expect { headers.add("", "foobar") }.
to raise_error HTTPX::HeaderError
end
it "fails with invalid header name" do
expect { headers.add("foo bar", "baz") }.
to raise_error HTTPX::HeaderError
end
end
describe "#get" do
before { headers["Content-Type"] = "application/json" }
it "returns array of associated values" do
expect(headers.get("content-type")).to eq %w[application/json]
end
it "normalizes header name" do
expect(headers.get("Content-Type")).to eq %w[application/json]
end
context "when header does not exists" do
it "returns empty array" do
expect(headers.get("accept")).to eq []
end
end
it "fails with empty header name" do
expect { headers.get("") }.
to raise_error HTTPX::HeaderError
end
it "fails with invalid header name" do
expect { headers.get("foo bar") }.
to raise_error HTTPX::HeaderError
end
end
describe "#[]" do
context "when header does not exists" do
it "returns nil" do
expect(headers["accept"]).to be_nil
end
end
context "when header has a single value" do
before { headers["content-type"] = "application/json" }
it "normalizes header name" do
expect(headers["content-type"]).to_not be_nil
end
it "returns it returns a single value" do
expect(headers["content-type"]).to eq "application/json"
end
end
context "when header has a multiple values" do
before do
headers.add "set-cookie", "hoo=ray"
headers.add "set-cookie", "woo=hoo"
end
it "normalizes header name" do
expect(headers["set-cookie"]).to_not be_nil
end
it "returns array of associated values" do
expect(headers.get("set-cookie")).to eq %w[hoo=ray woo=hoo]
end
end
end
describe "#each" do
before do
headers.add "set-cookie", "hoo=ray"
headers.add "content-type", "application/json"
headers.add "set-cookie", "woo=hoo"
end
it "yields each key/value pair separatedly" do
expect { |b| headers.each(&b) }.to yield_control.exactly(3).times
end
it "yields headers in the same order they were added" do
expect { |b| headers.each(&b) }.to yield_successive_args(
%w[set-cookie hoo=ray],
%w[set-cookie woo=hoo],
%w[content-type application/json],
)
end
it "returns self instance if block given" do
expect(headers.each { |*| }).to be headers
end
it "returns Enumerator if no block given" do
expect(headers.each).to be_a Enumerator
end
end
describe "#dup" do
before { headers["content-type"] = "application/json" }
subject(:dupped) { headers.dup }
it { is_expected.to be_a described_class }
it { is_expected.not_to be headers }
it "has headers copied" do
expect(dupped["content-type"]).to eq "application/json"
end
context "modifying a copy" do
before { dupped["content-type"] = "text/plain" }
it "modifies dupped copy" do
expect(dupped["content-type"]).to eq "text/plain"
end
it "does not affects original headers" do
expect(headers["content-type"]).to eq "application/json"
end
end
end
describe "#merge" do
before do
headers["host"] = "example.com"
headers["accept"] = "application/json"
end
subject(:merged) do
headers.merge "accept" => "plain/text", "cookie" => %w[hoo=ray woo=hoo]
end
it { is_expected.to be_a described_class }
it { is_expected.not_to be headers }
it "does not affects original headers" do
expect(merged).to_not eq headers
end
it "leaves headers not presented in other as is" do
expect(merged["host"]).to eq "example.com"
end
it "overwrites existing values" do
expect(merged["accept"]).to eq "plain/text"
end
it "appends other headers, not presented in base" do
expect(merged.get("cookie")).to eq %w[hoo=ray woo=hoo]
end
end
end

176
spec/httpx/options_spec.rb Normal file
View File

@ -0,0 +1,176 @@
# frozen_string_literal: true
RSpec.describe HTTPX::Options do
subject { described_class.new(:response => :body) }
it "has reader methods for attributes" do
expect(subject.response).to eq(:body)
end
it "coerces to a Hash" do
expect(subject.to_hash).to be_a(Hash)
end
describe "body" do
let(:opts) { HTTPX::Options.new }
it "defaults to nil" do
expect(opts.body).to be nil
end
it "may be specified with with_body" do
opts2 = opts.with_body("foo")
expect(opts.body).to be nil
expect(opts2.body).to eq("foo")
end
end
describe "form" do
let(:opts) { HTTPX::Options.new }
it "defaults to nil" do
expect(opts.form).to be nil
end
it "may be specified with with_form_data" do
opts2 = opts.with_form(:foo => 42)
expect(opts.form).to be nil
expect(opts2.form).to eq(:foo => 42)
end
end
describe "headers" do
let(:opts) { HTTPX::Options.new }
it "defaults to be empty" do
expect(opts.headers.to_a).to be_empty
end
it "may be specified with with_headers" do
opts2 = opts.with_headers("accept" => "json")
expect(opts.headers.to_a).to be_empty
expect(opts2.headers).to eq([%w[Accept json]])
end
end
describe "json" do
let(:opts) { HTTPX::Options.new }
it "defaults to nil" do
expect(opts.json).to be nil
end
it "may be specified with with_json data" do
opts2 = opts.with_json(:foo => 42)
expect(opts.json).to be nil
expect(opts2.json).to eq(:foo => 42)
end
end
describe "merge" do
let(:opts) { HTTPX::Options.new }
it "supports a Hash" do
old_response = opts.response
expect(opts.merge(:response => :body).response).to eq(:body)
expect(opts.response).to eq(old_response)
end
it "supports another Options" do
merged = opts.merge(HTTPX::Options.new(:response => :body))
expect(merged.response).to eq(:body)
end
it "merges as excepted in complex cases" do
# FIXME: yuck :(
foo = HTTPX::Options.new(
:response => :body,
:params => {:baz => "bar"},
:form => {:foo => "foo"},
:body => "body-foo",
:json => {:foo => "foo"},
:headers => {"accept" => "json", "foo" => "foo"},
:proxy => {},
)
bar = HTTPX::Options.new(
:response => :parsed_body,
:params => {:plop => "plip"},
:form => {:bar => "bar"},
:body => "body-bar",
:json => {:bar => "bar"},
:keep_alive_timeout => 10,
:headers => {"accept" => "xml", "bar" => "bar"},
:timeout_options => {:foo => :bar},
:ssl => {:foo => "bar"},
:proxy => {:proxy_address => "127.0.0.1", :proxy_port => 8080}
)
expect(foo.merge(bar).to_hash).to eq(
:response => :parsed_body,
:timeout_class => described_class.default_timeout_class,
:timeout_options => {:foo => :bar},
:params => {:plop => "plip"},
:form => {:bar => "bar"},
:body => "body-bar",
:json => {:bar => "bar"},
:keep_alive_timeout => 10,
:ssl => {:foo => "bar"},
:headers => HTTPX::Headers.new({"Foo" => "foo", "Accept" => "xml", "Bar" => "bar"}),
:proxy => {:proxy_address => "127.0.0.1", :proxy_port => 8080},
:follow => nil,
:ssl_context => nil,
:cookies => {},
)
end
end
describe "new" do
it "supports a Options instance" do
opts = HTTPX::Options.new
expect(HTTPX::Options.new(opts)).to eq(opts)
end
context "with a Hash" do
it "coerces :response correctly" do
opts = HTTPX::Options.new(:response => :object)
expect(opts.response).to eq(:object)
end
it "coerces :headers correctly" do
opts = HTTPX::Options.new(:headers => {"accept" => "json"})
expect(opts.headers).to eq([%w[Accept json]])
end
it "coerces :proxy correctly" do
opts = HTTPX::Options.new(:proxy => {:proxy_address => "127.0.0.1", :proxy_port => 8080})
expect(opts.proxy).to eq(:proxy_address => "127.0.0.1", :proxy_port => 8080)
end
it "coerces :form correctly" do
opts = HTTPX::Options.new(:form => {:foo => 42})
expect(opts.form).to eq(:foo => 42)
end
end
end
describe "proxy" do
let(:opts) { HTTPX::Options.new }
it "defaults to {}" do
expect(opts.proxy).to eq({})
end
it "may be specified with with_proxy" do
opts2 = opts.with_proxy(:proxy_address => "127.0.0.1", :proxy_port => 8080)
expect(opts.proxy).to eq({})
expect(opts2.proxy).to eq(:proxy_address => "127.0.0.1", :proxy_port => 8080)
end
it "accepts proxy address, port, username, and password" do
opts2 = opts.with_proxy(:proxy_address => "127.0.0.1", :proxy_port => 8080, :proxy_username => "username", :proxy_password => "password")
expect(opts2.proxy).to eq(:proxy_address => "127.0.0.1", :proxy_port => 8080, :proxy_username => "username", :proxy_password => "password")
end
end
end