diff --git a/README.md b/README.md index aec8d99..cc08b44 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,6 @@ Or install it yourself as: - Print kind error messages to make migration from Mechanize::Cookie easier -- Make serializers pluggable/autoloadable and prepare a binary friendly API - ## Contributing 1. Fork it diff --git a/lib/http/cookie.rb b/lib/http/cookie.rb index 8a388c2..2c6ac2b 100644 --- a/lib/http/cookie.rb +++ b/lib/http/cookie.rb @@ -20,8 +20,6 @@ class HTTP::Cookie secure httponly expires created_at accessed_at ] - True = "TRUE" - False = "FALSE" # In Ruby < 1.9.3 URI() does not accept an URI object. if RUBY_VERSION < "1.9.3" @@ -247,35 +245,6 @@ class HTTP::Cookie } } end - - # Parses a line from cookies.txt and returns a cookie object if - # the line represents a cookie record or returns nil otherwise. - def parse_cookiestxt_line(line) - return nil if line.match(/^#/) - - domain, - s_for_domain, # Whether this cookie is for domain - path, # Path for which the cookie is relevant - s_secure, # Requires a secure connection - s_expires, # Time the cookie expires (Unix epoch time) - name, value = line.split("\t", 7) - return nil if value.nil? - - value.chomp! - - if (expires_seconds = s_expires.to_i).nonzero? - expires = Time.at(expires_seconds) - return nil if expires < Time.now - end - - HTTP::Cookie.new(name, value, - :domain => domain, - :for_domain => s_for_domain == True, - :path => path, - :secure => s_secure == True, - :expires => expires, - :version => 0) - end end def name=(name) @@ -392,19 +361,6 @@ class HTTP::Cookie end alias to_s cookie_value - # Serializes the cookie into a cookies.txt line. - def to_cookiestxt_line(linefeed = "\n") - [ - @domain, - @for_domain ? True : False, - @path, - @secure ? True : False, - @expires.to_i, - @name, - @value - ].join("\t") << linefeed - end - # Returns a string for use in a Set-Cookie header value. If the # cookie does not have an origin set, one must be given from the # argument. diff --git a/lib/http/cookie_jar.rb b/lib/http/cookie_jar.rb index e476dd3..8b21214 100644 --- a/lib/http/cookie_jar.rb +++ b/lib/http/cookie_jar.rb @@ -7,7 +7,7 @@ end # any particular website. class HTTP::CookieJar - include Enumerable + autoload :AbstractSaver, 'http/cookie_jar/abstract_saver' attr_reader :jar @@ -75,13 +75,15 @@ class HTTP::CookieJar } self end + include Enumerable # call-seq: - # jar.save_as(file, format = :yaml) - # jar.save_as(file, options) + # jar.save(filename_or_io, **options) + # jar.save(filename_or_io, format = :yaml, **options) # - # Save the cookie jar to a file in the format specified and return - # self. + # Save the cookie jar into a file or an IO in the format specified + # and return self. If the given object responds to #write it is + # taken as an IO, or taken as a filename otherwise. # # Available option keywords are below: # @@ -95,65 +97,108 @@ class HTTP::CookieJar # Save session cookies as well. # [+false+] # Do not save session cookies. (default) - def save_as(file, options = nil) - if Symbol === options - format = options - session = false + # + # All options given are passed through to the underlying cookie + # saver module. + def save(writable, *options) + opthash = { + :format => :yaml, + :session => false, + } + case options.size + when 0 + when 1 + case options = options.first + when Symbol + opthash[:format] = options + else + opthash.update(options) if options + end + when 2 + opthash[:format], options = options + opthash.update(options) if options else - options ||= {} - format = options[:format] || :yaml - session = !!options[:session] + raise ArgumentError, 'wrong number of arguments (%d for 1-3)' % (1 + options.size) end - jar = dup - jar.cleanup !session + begin + saver = AbstractSaver.implementation(opthash[:format]).new(opthash) + rescue KeyError => e + raise ArgumentError, e.message + end - open(file, 'w') { |f| - case format - when :yaml then - require_yaml - - YAML.dump(jar.jar, f) - when :cookiestxt then - jar.dump_cookiestxt(f) - else - raise ArgumentError, "Unknown cookie jar file format" - end - } + if writable.respond_to?(:write) + saver.save(writable, self) + else + File.open(writable, 'w') { |io| + saver.save(io, self) + } + end self end - # Load cookie jar from a file in the format specified. - # - # Available formats: - # :yaml <- YAML structure. - # :cookiestxt <- Mozilla's cookies.txt format - def load(file, format = :yaml) - File.open(file) { |f| - case format - when :yaml then - require_yaml - @jar = YAML.load(f) - when :cookiestxt then - load_cookiestxt(f) - else - raise ArgumentError, "Unknown cookie jar file format" - end - } - - cleanup + # An obsolete name for save(). + def save_as(*args) + warn "%s() is obsolete; use save()." % __method__ + save(*args) end - def require_yaml # :nodoc: - begin - require 'psych' - rescue LoadError + # call-seq: + # jar.load(filename_or_io, **options) + # jar.load(filename_or_io, format = :yaml, **options) + # + # Load cookies recorded in a file or an IO in the format specified + # into the jar and return self. If the given object responds to + # #read it is taken as an IO, or taken as a filename otherwise. + # + # Available option keywords are below: + # + # * +format+ + # [:yaml] + # YAML structure (default) + # [:cookiestxt] + # Mozilla's cookies.txt format + # + # All options given are passed through to the underlying cookie + # saver module. + def load(readable, *options) + opthash = { + :format => :yaml, + :session => false, + } + case options.size + when 0 + when 1 + case options = options.first + when Symbol + opthash[:format] = options + else + opthash.update(options) if options + end + when 2 + opthash[:format], options = options + opthash.update(options) if options + else + raise ArgumentError, 'wrong number of arguments (%d for 1-3)' % (1 + options.size) end - require 'yaml' + begin + saver = AbstractSaver.implementation(opthash[:format]).new(opthash) + rescue KeyError => e + raise ArgumentError, e.message + end + + if readable.respond_to?(:write) + saver.load(readable, self) + else + File.open(readable, 'r') { |io| + saver.load(io, self) + } + end + + self end - private :require_yaml # Clear the cookie jar and return self. def clear @@ -161,26 +206,6 @@ class HTTP::CookieJar self end - # Read cookies from Mozilla cookies.txt-style IO stream and return - # self. - def load_cookiestxt(io) - io.each_line do |line| - c = HTTP::Cookie.parse_cookiestxt_line(line) and add(c) - end - - self - end - - # Write cookies to Mozilla cookies.txt-style IO stream and return - # self. - def dump_cookiestxt(io) - io.puts "# HTTP Cookie File" - to_a.each do |cookie| - io.print cookie.to_cookiestxt_line - end - self - end - protected # Remove expired cookies and return self. diff --git a/lib/http/cookie_jar/abstract_saver.rb b/lib/http/cookie_jar/abstract_saver.rb new file mode 100644 index 0000000..af5a706 --- /dev/null +++ b/lib/http/cookie_jar/abstract_saver.rb @@ -0,0 +1,53 @@ +require 'http/cookie_jar' + +class HTTP::CookieJar::AbstractSaver + class << self + @@class_map = {} + + # Gets an implementation class by the name, optionally trying to + # load "http/cookie_jar/*_saver" if not found. If loading fails, + # KeyError is raised. + def implementation(symbol) + @@class_map.fetch(symbol) + rescue KeyError + begin + require 'http/cookie_jar/%s_saver' % symbol + @@class_map.fetch(symbol) + rescue LoadError, KeyError => e + raise KeyError, 'cookie saver unavailable: %s' % symbol.inspect + end + end + + def inherited(subclass) + @@class_map[class_to_symbol(subclass)] = subclass + end + + def class_to_symbol(klass) + klass.name[/[^:]+?(?=Saver$|$)/].downcase.to_sym + end + end + + def default_options + {} + end + private :default_options + + def initialize(options = nil) + options ||= {} + @logger = options[:logger] + @session = options[:session] + # Initializes each instance variable of the same name as option + # keyword. + default_options.each_pair { |key, default| + instance_variable_set("@#{key}", options.key?(key) ? options[key] : default) + } + end + + def save(io, jar) + raise + end + + def load(io, jar) + raise + end +end diff --git a/lib/http/cookie_jar/cookiestxt_saver.rb b/lib/http/cookie_jar/cookiestxt_saver.rb new file mode 100644 index 0000000..442af14 --- /dev/null +++ b/lib/http/cookie_jar/cookiestxt_saver.rb @@ -0,0 +1,74 @@ +require 'http/cookie_jar' + +# CookiestxtSaver saves and loads cookies in the cookies.txt format. +class HTTP::CookieJar::CookiestxtSaver < HTTP::CookieJar::AbstractSaver + True = "TRUE" + False = "FALSE" + + def save(io, jar) + io.puts @header if @header + jar.each { |cookie| + next if !@session && cookie.session? + io.print cookie_to_record(cookie) + } + end + + def load(io, jar) + io.each_line { |line| + cookie = parse_record(line) and jar.add(cookie) + } + end + + private + + def default_options + { + header: "# HTTP Cookie File", + linefeed: "\n", + } + end + + # Serializes the cookie into a cookies.txt line. + def cookie_to_record(cookie) + cookie.instance_eval { + [ + @domain, + @for_domain ? True : False, + @path, + @secure ? True : False, + @expires.to_i, + @name, + @value + ] + }.join("\t") << @linefeed + end + + # Parses a line from cookies.txt and returns a cookie object if the + # line represents a cookie record or returns nil otherwise. + def parse_record(line) + return nil if line.match(/^#/) + + domain, + s_for_domain, # Whether this cookie is for domain + path, # Path for which the cookie is relevant + s_secure, # Requires a secure connection + s_expires, # Time the cookie expires (Unix epoch time) + name, value = line.split("\t", 7) + return nil if value.nil? + + value.chomp! + + if (expires_seconds = s_expires.to_i).nonzero? + expires = Time.at(expires_seconds) + return nil if expires < Time.now + end + + HTTP::Cookie.new(name, value, + :domain => domain, + :for_domain => s_for_domain == True, + :path => path, + :secure => s_secure == True, + :expires => expires, + :version => 0) + end +end diff --git a/lib/http/cookie_jar/yaml_saver.rb b/lib/http/cookie_jar/yaml_saver.rb new file mode 100644 index 0000000..a9f66b5 --- /dev/null +++ b/lib/http/cookie_jar/yaml_saver.rb @@ -0,0 +1,30 @@ +require 'http/cookie_jar' +begin + require 'psych' +rescue LoadError +end +require 'yaml' + +# YAMLSaver saves and loads cookies in the YAML format. +class HTTP::CookieJar::YAMLSaver < HTTP::CookieJar::AbstractSaver + def save(io, jar) + YAML.dump(@session ? jar.to_a : jar.reject(&:session?), io) + end + + def load(io, jar) + begin + YAML.load(io) + rescue ArgumentError + @logger.warn "incompatible YAML cookie data discarded" if @logger + return + end.each { |cookie| + jar.add(cookie) + } + end + + private + + def default_options + {} + end +end diff --git a/test/test_http_cookie_jar.rb b/test/test_http_cookie_jar.rb index 75cd2a8..467276d 100644 --- a/test/test_http_cookie_jar.rb +++ b/test/test_http_cookie_jar.rb @@ -301,7 +301,7 @@ class TestHTTPCookieJar < Test::Unit::TestCase assert_equal(3, @jar.cookies(url).length) in_tmpdir do - value = @jar.save_as("cookies.yml") + value = @jar.save("cookies.yml") assert_same @jar, value jar = HTTP::CookieJar.new @@ -333,7 +333,7 @@ class TestHTTPCookieJar < Test::Unit::TestCase assert_equal(3, @jar.cookies(url).length) in_tmpdir do - @jar.save_as("cookies.yml", :format => :yaml, :session => true) + @jar.save("cookies.yml", :format => :yaml, :session => true) jar = HTTP::CookieJar.new jar.load("cookies.yml") @@ -365,7 +365,7 @@ class TestHTTPCookieJar < Test::Unit::TestCase assert_equal(3, @jar.cookies(url).length) in_tmpdir do - @jar.save_as("cookies.txt", :cookiestxt) + @jar.save("cookies.txt", :cookiestxt) jar = HTTP::CookieJar.new jar.load("cookies.txt", :cookiestxt) # HACK test the format