Skip to content

Commit

Permalink
Merge pull request #92 from tarcieri/feature/improve-headers
Browse files Browse the repository at this point in the history
Refactor headers
  • Loading branch information
tarcieri committed Mar 18, 2014
2 parents 2562d4a + 5d9c07d commit 9fa6f3f
Show file tree
Hide file tree
Showing 8 changed files with 569 additions and 87 deletions.
155 changes: 125 additions & 30 deletions lib/http/headers.rb
Original file line number Diff line number Diff line change
@@ -1,55 +1,150 @@
require 'forwardable'
require 'delegate'

require 'http/headers/mixin'

module HTTP
# Headers Hash wraper with keys normalization
class Headers < ::Delegator
module Mixin
extend Forwardable
attr_reader :headers
def_delegators :headers, :[], :[]=
end
class Headers
extend Forwardable

# Matches HTTP header names when in "Canonical-Http-Format"
CANONICAL_HEADER = /^[A-Z][a-z]*(-[A-Z][a-z]*)*$/

def initialize(obj = {})
super({})
__setobj__ obj
# :nodoc:
def initialize
@pile = []
end

# Transform to canonical HTTP header capitalization
def canonicalize_header(header)
header.to_s.split(/[\-_]/).map(&:capitalize).join('-')
# Sets header
#
# @return [void]
def set(name, value)
delete(name)
add(name, value)
end
alias_method :[]=, :set

# Obtain the given header
def [](name)
super(name) || super(canonicalize_header name)
# Removes header
#
# @return [void]
def delete(name)
name = canonicalize_header name.to_s
@pile.delete_if { |k, _| k == name }
end

# Append header
#
# @return [void]
def add(name, value)
name = canonicalize_header name.to_s
Array(value).each { |v| @pile << [name, v] }
end
alias_method :append, :add

# Set a header
def []=(name, value)
# If we have a canonical header, we're done, canonicalize otherwise
name = name.to_s[CANONICAL_HEADER] || canonicalize_header(name)
# Return array of header values if any.
#
# @return [Array]
def get(name)
name = canonicalize_header name.to_s
@pile.select { |k, _| k == name }.map { |_, v| v }
end

# Check if the header has already been set and group
value = Array(self[name]) + Array(value) if key? name
# Smart version of {#get}
#
# @return [NilClass] if header was not set
# @return [Object] if header has exactly one value
# @return [Array<Object>] if header has more than one value
def [](name)
values = get(name)

super name, value
case values.count
when 0 then nil
when 1 then values.first
else values
end
end

protected
# Converts headers into a Rack-compatible Hash
#
# @return [Hash]
def to_h
Hash[keys.map { |k| [k, self[k]] }]
end

# Array of key/value pairs
#
# @return [Array<[String, String]>]
def to_a
@pile.map { |pair| pair.map(&:dup) }
end

# :nodoc:
def __getobj__
@headers
def inspect
"#<#{self.class} #{to_h.inspect}>"
end

# List of header names
#
# @return [Array<String>]
def keys
@pile.map { |k, _| k }.uniq
end

# Compares headers to another Headers or Array of key/value pairs
#
# @return [Boolean]
def ==(other)
return false unless other.respond_to? :to_a
@pile == other.to_a
end

def_delegators :@pile, :each, :hash

# :nodoc:
def __setobj__(obj)
@headers = {}
obj.each { |k, v| self[k] = v } if obj.respond_to? :each
def initialize_copy(orig)
super
@pile = to_a
end

# Merge in `other` headers
#
# @see #merge
# @return [void]
def merge!(other)
self.class.from_hash(other).to_h.each { |name, values| set name, values }
end

# Returns new Headers instance with `other` headers merged in.
#
# @see #merge!
# @return [Headers]
def merge(other)
dup.tap { |dupped| dupped.merge! other }
end

# Initiates new Headers object from given Hash
#
# @raise [Error] if given hash does not respond to `#to_hash` or `#to_h`
# @param [#to_hash, #to_h] hash
# @return [Headers]
def self.from_hash(hash)
hash = case
when hash.respond_to?(:to_hash) then hash.to_hash
when hash.respond_to?(:to_h) then hash.to_h
else fail Error, '#to_hash or #to_h object expected'
end

headers = new
hash.each { |k, v| headers.add k, v }
headers
end

private

# Transform to canonical HTTP header capitalization
# @param [String] name
# @return [String]
def canonicalize_header(name)
name[CANONICAL_HEADER] || name.split(/[\-_]/).map(&:capitalize).join('-')
end
end
end
11 changes: 11 additions & 0 deletions lib/http/headers/mixin.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
require 'forwardable'

module HTTP
class Headers
module Mixin
extend Forwardable
attr_reader :headers
def_delegators :headers, :[], :[]=
end
end
end
4 changes: 2 additions & 2 deletions lib/http/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,15 @@ def initialize(verb, uri, headers = {}, proxy = {}, body = nil, version = '1.1')

@proxy, @body, @version = proxy, body, version

@headers = HTTP::Headers.new(headers)
@headers = HTTP::Headers.from_hash(headers || {})
@headers['Host'] ||= @uri.host
end

# Returns new Request with updated uri
def redirect(uri)
uri = @uri.merge uri.to_s
req = self.class.new(verb, uri, headers, proxy, body, version)
req.headers.merge!('Host' => req.uri.host)
req['Host'] = req.uri.host
req
end

Expand Down
4 changes: 2 additions & 2 deletions lib/http/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class Response

def initialize(status, version, headers, body, uri = nil) # rubocop:disable ParameterLists
@status, @version, @body, @uri = status, version, body, uri
@headers = HTTP::Headers.new(headers)
@headers = HTTP::Headers.from_hash(headers || {})
end

# Obtain the 'Reason-Phrase' for the response
Expand All @@ -86,7 +86,7 @@ def reason

# Returns an Array ala Rack: `[status, headers, body]`
def to_a
[status, headers, body.to_s]
[status, headers.to_h, body.to_s]
end

# Return the response body as a string
Expand Down
36 changes: 36 additions & 0 deletions spec/http/headers/mixin_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
require 'spec_helper'

describe HTTP::Headers::Mixin do
let :dummy_class do
Class.new do
include HTTP::Headers::Mixin

def initialize(headers)
@headers = headers
end
end
end

let(:headers) { HTTP::Headers.new }
let(:dummy) { dummy_class.new headers }

describe '#headers' do
it 'returns @headers instance variable' do
expect(dummy.headers).to be headers
end
end

describe '#[]' do
it 'proxies to headers#[]' do
expect(headers).to receive(:[]).with(:accept)
dummy[:accept]
end
end

describe '#[]=' do
it 'proxies to headers#[]' do
expect(headers).to receive(:[]=).with(:accept, 'text/plain')
dummy[:accept] = 'text/plain'
end
end
end
Loading

0 comments on commit 9fa6f3f

Please sign in to comment.