diff --git a/README.md b/README.md index 874dafc..b0a6c09 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,14 @@ Certain Ruby application servers work by forking, and it has long been necessary re-establish the child process's connection to the database after fork. But with the release of v1.3.0, the Ruby driver detects forking and reconnects automatically. +## Environment variable `MONGODB_URI` + +`Mongo::Connection.new` and `Mongo::ReplSetConnection.new` will use ENV["MONGODB_URI"] if no other args are provided. + +The URI must fit this specification: + + mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]] + ## String Encoding The BSON ("Binary JSON") format used to communicate with Mongo requires that diff --git a/lib/mongo/connection.rb b/lib/mongo/connection.rb index e8eaf58..5f48833 100644 --- a/lib/mongo/connection.rb +++ b/lib/mongo/connection.rb @@ -32,6 +32,7 @@ module Mongo Thread.abort_on_exception = true + DEFAULT_HOST = 'localhost' DEFAULT_PORT = 27017 GENERIC_OPTS = [:ssl, :auths, :pool_size, :pool_timeout, :timeout, :op_timeout, :connect_timeout, :safe, :logger, :connect] CONNECTION_OPTS = [:slave_ok] @@ -44,6 +45,8 @@ module Mongo # Create a connection to single MongoDB instance. # + # If no args are provided, it will check ENV["MONGODB_URI"]. + # # You may specify whether connection to slave is permitted. # In all cases, the default host is "localhost" and the default port is 27017. # @@ -76,7 +79,7 @@ module Mongo # connection attempt. # @option opts [Boolean] :ssl (false) If true, create the connection to the server using SSL. # - # @example localhost, 27017 + # @example localhost, 27017 (or ENV["MONGODB_URI"] if available) # Mongo::Connection.new # # @example localhost, 27017 @@ -93,9 +96,22 @@ module Mongo # @raise [ReplicaSetConnectionError] This is raised if a replica set name is specified and the # driver fails to connect to a replica set with that name. # + # @raise [MongoArgumentError] If called with no arguments and ENV["MONGODB_URI"] implies a replica set. + # # @core self.connections def initialize(host=nil, port=nil, opts={}) - @host_to_try = format_pair(host, port) + if host.nil? and ENV.has_key?('MONGODB_URI') + parser = URIParser.new ENV['MONGODB_URI'], opts + if parser.replicaset? + raise MongoArgumentError, "Mongo::Connection.new called with no arguments, but ENV['MONGODB_URI'] implies a replica set." + end + opts = parser.connection_options + @host_to_try = [parser.host, parser.port] + elsif host.is_a?(String) + @host_to_try = [host, (port || DEFAULT_PORT).to_i] + else + @host_to_try = [DEFAULT_HOST, DEFAULT_PORT] + end # Host and port of current master. @host = @port = nil @@ -143,8 +159,7 @@ module Mongo def self.multi(nodes, opts={}) warn "Connection.multi is now deprecated and will be removed in v2.0. Please use ReplSetConnection.new instead." - nodes << opts - ReplSetConnection.new(*nodes) + ReplSetConnection.new(*(nodes+[opts])) end # Initialize a connection to MongoDB using the MongoDB URI spec: @@ -155,21 +170,9 @@ module Mongo # @param opts Any of the options available for Connection.new # # @return [Mongo::Connection, Mongo::ReplSetConnection] - def self.from_uri(string, extra_opts={}) - uri = URIParser.new(string) - opts = uri.connection_options - opts.merge!(extra_opts) - - if uri.nodes.length == 1 - opts.merge!({:auths => uri.auths}) - Connection.new(uri.nodes[0][0], uri.nodes[0][1], opts) - elsif uri.nodes.length > 1 - nodes = uri.nodes.clone - nodes_with_opts = nodes << opts - ReplSetConnection.new(*nodes_with_opts) - else - raise MongoArgumentError, "No nodes specified. Please ensure that you've provided at least one node." - end + def self.from_uri(uri, extra_opts={}) + parser = URIParser.new uri, extra_opts + parser.connection end # The host name used for this connection. @@ -337,7 +340,7 @@ module Mongo # @param [String] from_host host of the 'from' database. # @param [String] username username for authentication against from_db (>=1.3.x). # @param [String] password password for authentication against from_db (>=1.3.x). - def copy_database(from, to, from_host="localhost", username=nil, password=nil) + def copy_database(from, to, from_host=DEFAULT_HOST, username=nil, password=nil) oh = BSON::OrderedHash.new oh[:copydb] = 1 oh[:fromhost] = from_host @@ -585,23 +588,8 @@ module Mongo write_logging_startup_message end - should_connect = opts.fetch(:connect, true) - connect if should_connect - end - - ## Configuration helper methods - - # Returns a host-port pair. - # - # @return [Array] - # - # @private - def format_pair(host, port) - case host - when String - [host, port ? port.to_i : DEFAULT_PORT] - when nil - ['localhost', DEFAULT_PORT] + if opts.fetch(:connect, true) + connect end end diff --git a/lib/mongo/repl_set_connection.rb b/lib/mongo/repl_set_connection.rb index cc3552e..017525d 100644 --- a/lib/mongo/repl_set_connection.rb +++ b/lib/mongo/repl_set_connection.rb @@ -29,6 +29,8 @@ module Mongo # Create a connection to a MongoDB replica set. # + # If no args are provided, it will check ENV["MONGODB_URI"]. + # # Once connected to a replica set, you can find out which nodes are primary, secondary, and # arbiters with the corresponding accessors: Connection#primary, Connection#secondaries, and # Connection#arbiters. This is useful if your application needs to connect manually to nodes other @@ -78,6 +80,8 @@ module Mongo # # @see http://api.mongodb.org/ruby/current/file.REPLICA_SETS.html Replica sets in Ruby # + # @raise [MongoArgumentError] If called with no arguments and ENV["MONGODB_URI"] implies a direct connection. + # # @raise [ReplicaSetConnectionError] This is raised if a replica set name is specified and the # driver fails to connect to a replica set with that name. def initialize(*args) @@ -87,21 +91,30 @@ module Mongo opts = {} end - unless args.length > 0 + nodes = args + + if nodes.empty? and ENV.has_key?('MONGODB_URI') + parser = URIParser.new ENV['MONGODB_URI'], opts + if parser.direct? + raise MongoArgumentError, "Mongo::ReplSetConnection.new called with no arguments, but ENV['MONGODB_URI'] implies a direct connection." + end + opts = parser.connection_options + nodes = parser.nodes + end + + unless nodes.length > 0 raise MongoArgumentError, "A ReplSetConnection requires at least one seed node." end # This is temporary until support for the old format is dropped - @seeds = [] - if args.first.last.is_a?(Integer) + if nodes.first.last.is_a?(Integer) warn "Initiating a ReplSetConnection with seeds passed as individual [host, port] array arguments is deprecated." warn "Please specify hosts as an array of 'host:port' strings; the old format will be removed in v2.0" - @seeds = args + @seeds = nodes else - args.first.map do |host_port| - seed = host_port.split(":") - seed[1] = seed[1].to_i - seeds << seed + @seeds = nodes.first.map do |host_port| + host, port = host_port.split(":") + [ host, port.to_i ] end end diff --git a/lib/mongo/util/uri_parser.rb b/lib/mongo/util/uri_parser.rb index a0154f7..2c42993 100644 --- a/lib/mongo/util/uri_parser.rb +++ b/lib/mongo/util/uri_parser.rb @@ -16,11 +16,11 @@ # limitations under the License. # ++ +require 'uri' + module Mongo class URIParser - DEFAULT_PORT = 27017 - USER_REGEX = /([-.\w:]+)/ PASS_REGEX = /([^@,]+)/ AUTH_REGEX = /(#{USER_REGEX}:#{PASS_REGEX}@)?/ @@ -37,7 +37,7 @@ module Mongo SPEC_ATTRS = [:nodes, :auths] OPT_ATTRS = [:connect, :replicaset, :slaveok, :safe, :w, :wtimeout, :fsync, :journal, :connecttimeoutms, :sockettimeoutms, :wtimeoutms] - OPT_VALID = {:connect => lambda {|arg| ['direct', 'replicaset'].include?(arg)}, + OPT_VALID = {:connect => lambda {|arg| ['direct', 'replicaset', 'true', 'false', true, false].include?(arg)}, :replicaset => lambda {|arg| arg.length > 0}, :slaveok => lambda {|arg| ['true', 'false'].include?(arg)}, :safe => lambda {|arg| ['true', 'false'].include?(arg)}, @@ -50,7 +50,7 @@ module Mongo :wtimeoutms => lambda {|arg| arg =~ /^\d+$/ } } - OPT_ERR = {:connect => "must be 'direct' or 'replicaset'", + OPT_ERR = {:connect => "must be 'direct', 'replicaset', 'true', or 'false'", :replicaset => "must be a string containing the name of the replica set to connect to", :slaveok => "must be 'true' or 'false'", :safe => "must be 'true' or 'false'", @@ -63,7 +63,7 @@ module Mongo :wtimeoutms => "must be an integer specifying milliseconds" } - OPT_CONV = {:connect => lambda {|arg| arg}, + OPT_CONV = {:connect => lambda {|arg| arg == 'false' ? false : arg}, # be sure to convert 'false' to FalseClass :replicaset => lambda {|arg| arg}, :slaveok => lambda {|arg| arg == 'true' ? true : false}, :safe => lambda {|arg| arg == 'true' ? true : false}, @@ -81,22 +81,73 @@ module Mongo # Parse a MongoDB URI. This method is used by Connection.from_uri. # Returns an array of nodes and an array of db authorizations, if applicable. # - # Note: passwords can contain any character except for a ','. + # @note Passwords can contain any character except for ',' + # + # @param [String] uri The MongoDB URI string. + # @param [Hash,nil] extra_opts Extra options. Will override anything already specified in the URI. # # @core connections - def initialize(string) - if string =~ /^mongodb:\/\// - string = string[10..-1] + def initialize(uri, extra_opts={}) + if uri.start_with?('mongodb://') + uri = uri[10..-1] else raise MongoArgumentError, "MongoDB URI must match this spec: #{MONGODB_URI_SPEC}" end - hosts, opts = string.split('?') + hosts, opts = uri.split('?') parse_hosts(hosts) - parse_options(opts) - configure_connect + parse_options(opts, extra_opts) + validate_connect end + # Create a Mongo::Connection or a Mongo::ReplSetConnection based on the URI. + # + # @note Don't confuse this with attribute getter method #connect. + # + # @return [Connection,ReplSetConnection] + def connection + if replicaset? + ReplSetConnection.new(*(nodes+[connection_options])) + else + Connection.new(host, port, connection_options) + end + end + + # Whether this represents a replica set. + # @return [true,false] + def replicaset? + replicaset.is_a?(String) || nodes.length > 1 + end + + # Whether to immediately connect to the MongoDB node[s]. Defaults to true. + # @return [true, false] + def connect? + connect != false + end + + # Whether this represents a direct connection. + # + # @note Specifying :connect => 'direct' has no effect... other than to raise an exception if other variables suggest a replicaset. + # + # @return [true,false] + def direct? + !replicaset? + end + + # For direct connections, the host of the (only) node. + # @return [String] + def host + nodes[0][0] + end + + # For direct connections, the port of the (only) node. + # @return [Integer] + def port + nodes[0][1].to_i + end + + # Options that can be passed to Mongo::Connection.new or Mongo::ReplSetConnection.new + # @return [Hash] def connection_options opts = {} @@ -136,14 +187,22 @@ module Mongo end if @slaveok - if @connect == 'direct' + if direct? opts[:slave_ok] = true else opts[:read] = :secondary end end - opts[:name] = @replicaset if @replicaset + if direct? + opts[:auths] = auths + end + + if replicaset.is_a?(String) + opts[:name] = replicaset + end + + opts[:connect] = connect? opts end @@ -167,7 +226,7 @@ module Mongo hosturis.each do |hosturi| # If port is present, use it, otherwise use default port - host, port = hosturi.split(':') + [DEFAULT_PORT] + host, port = hosturi.split(':') + [Connection::DEFAULT_PORT] if !(port.to_s =~ /^\d+$/) raise MongoArgumentError, "Invalid port #{port}; port must be specified as digits." @@ -178,6 +237,10 @@ module Mongo @nodes << [host, port] end + if @nodes.empty? + raise MongoArgumentError, "No nodes specified. Please ensure that you've provided at least one node." + end + if uname && pwd && db auths << {'db_name' => db, 'username' => uname, 'password' => pwd} elsif uname || pwd @@ -191,40 +254,37 @@ module Mongo # This method uses the lambdas defined in OPT_VALID and OPT_CONV to validate # and convert the given options. - def parse_options(opts) + def parse_options(string_opts, extra_opts={}) # initialize instance variables for available options OPT_VALID.keys.each { |k| instance_variable_set("@#{k}", nil) } - return unless opts + string_opts ||= '' - separator = opts.include?('&') ? '&' : ';' - opts.split(separator).each do |attr| - key, value = attr.split('=') - key = key.downcase.to_sym - value = value.strip.downcase + return if string_opts.empty? && extra_opts.empty? + + opts = URI.decode_www_form(string_opts).inject({}) do |memo, (key, value)| + memo[key.downcase.to_sym] = value.strip.downcase + memo + end + + opts.merge! extra_opts + + opts.each do |key, value| if !OPT_ATTRS.include?(key) raise MongoArgumentError, "Invalid Mongo URI option #{key}" end - if OPT_VALID[key].call(value) instance_variable_set("@#{key}", OPT_CONV[key].call(value)) else - raise MongoArgumentError, "Invalid value for #{key}: #{OPT_ERR[key]}" + raise MongoArgumentError, "Invalid value #{value.inspect} for #{key}: #{OPT_ERR[key]}" end end end - def configure_connect - if !@connect - if @nodes.length > 1 - @connect = 'replicaset' - else - @connect = 'direct' - end - end - - if @connect == 'direct' && @replicaset - raise MongoArgumentError, "If specifying a replica set name, please also specify that connect=replicaset" + def validate_connect + if replicaset? and @connect == 'direct' + # Make sure the user doesn't specify something contradictory + raise MongoArgumentError, "connect=direct conflicts with setting a replicaset name" end end end diff --git a/test/connection_test.rb b/test/connection_test.rb index 6e9ab7a..bcfe773 100644 --- a/test/connection_test.rb +++ b/test/connection_test.rb @@ -58,6 +58,18 @@ class TestConnection < Test::Unit::TestCase assert_equal mongo_port, con.primary_pool.port end + def test_env_mongodb_uri + begin + old_mongodb_uri = ENV['MONGODB_URI'] + ENV['MONGODB_URI'] = "mongodb://#{host_port}" + con = Connection.new + assert_equal mongo_host, con.primary_pool.host + assert_equal mongo_port, con.primary_pool.port + ensure + ENV['MONGODB_URI'] = old_mongodb_uri + end + end + def test_server_version assert_match(/\d\.\d+(\.\d+)?/, @conn.server_version.to_s) end diff --git a/test/replica_sets/connect_test.rb b/test/replica_sets/connect_test.rb index a608873..11f66af 100644 --- a/test/replica_sets/connect_test.rb +++ b/test/replica_sets/connect_test.rb @@ -105,6 +105,20 @@ class ConnectTest < Test::Unit::TestCase assert @conn.is_a?(ReplSetConnection) assert @conn.connected? end + + def test_connect_with_connection_string_in_env_var + begin + old_mongodb_uri = ENV['MONGODB_URI'] + ENV['MONGODB_URI'] = "mongodb://#{@rs.host}:#{@rs.ports[0]},#{@rs.host}:#{@rs.ports[1]}?replicaset=#{@rs.name}" + silently do + @conn = ReplSetConnection.new + end + assert @conn.is_a?(ReplSetConnection) + assert @conn.connected? + ensure + ENV['MONGODB_URI'] = old_mongodb_uri + end + end def test_connect_with_new_seed_format @conn = ReplSetConnection.new build_seeds(3) @@ -128,4 +142,21 @@ class ConnectTest < Test::Unit::TestCase assert @conn.safe[:fsync] assert @conn.read_pool end + + def test_connect_with_full_connection_string_in_env_var + begin + old_mongodb_uri = ENV['MONGODB_URI'] + ENV['MONGODB_URI'] = "mongodb://#{@rs.host}:#{@rs.ports[0]},#{@rs.host}:#{@rs.ports[1]}?replicaset=#{@rs.name};safe=true;w=2;fsync=true;slaveok=true" + silently do + @conn = ReplSetConnection.new + end + assert @conn.is_a?(ReplSetConnection) + assert @conn.connected? + assert_equal 2, @conn.safe[:w] + assert @conn.safe[:fsync] + assert @conn.read_pool + ensure + ENV['MONGODB_URI'] = old_mongodb_uri + end + end end diff --git a/test/unit/connection_test.rb b/test/unit/connection_test.rb index a4ab0d3..2f54a40 100644 --- a/test/unit/connection_test.rb +++ b/test/unit/connection_test.rb @@ -130,5 +130,93 @@ class ConnectionTest < Test::Unit::TestCase end end end + + context "initializing with ENV['MONGODB_URI']" do + setup do + @old_mongodb_uri = ENV['MONGODB_URI'] + end + + teardown do + ENV['MONGODB_URI'] = @old_mongodb_uri + end + + should "parse a simple uri" do + ENV['MONGODB_URI'] = "mongodb://localhost?connect=false" + @conn = Connection.new + assert_equal ['localhost', 27017], @conn.host_to_try + end + + should "allow a complex host names" do + host_name = "foo.bar-12345.org" + ENV['MONGODB_URI'] = "mongodb://#{host_name}?connect=false" + @conn = Connection.new + assert_equal [host_name, 27017], @conn.host_to_try + end + + should "allow db without username and password" do + host_name = "foo.bar-12345.org" + ENV['MONGODB_URI'] = "mongodb://#{host_name}/foo?connect=false" + @conn = Connection.new + assert_equal [host_name, 27017], @conn.host_to_try + end + + should "set safe options on connection" do + host_name = "localhost" + opts = "safe=true&w=2&wtimeoutMS=1000&fsync=true&journal=true&connect=false" + ENV['MONGODB_URI'] = "mongodb://#{host_name}/foo?#{opts}" + @conn = Connection.new + assert_equal({:w => 2, :wtimeout => 1000, :fsync => true, :j => true}, @conn.safe) + end + + should "set timeout options on connection" do + host_name = "localhost" + opts = "connectTimeoutMS=1000&socketTimeoutMS=5000&connect=false" + ENV['MONGODB_URI'] = "mongodb://#{host_name}/foo?#{opts}" + @conn = Connection.new + assert_equal 1, @conn.connect_timeout + assert_equal 5, @conn.op_timeout + end + + should "parse a uri with a hyphen & underscore in the username or password" do + ENV['MONGODB_URI'] = "mongodb://hyphen-user_name:p-s_s@localhost:27017/db?connect=false" + @conn = Connection.new + assert_equal ['localhost', 27017], @conn.host_to_try + auth_hash = { 'db_name' => 'db', 'username' => 'hyphen-user_name', "password" => 'p-s_s' } + assert_equal auth_hash, @conn.auths[0] + end + + should "attempt to connect" do + TCPSocket.stubs(:new).returns(new_mock_socket) + ENV['MONGODB_URI'] = "mongodb://localhost?connect=false" # connect=false ?? + @conn = Connection.new + + admin_db = new_mock_db + admin_db.expects(:command).returns({'ok' => 1, 'ismaster' => 1}) + @conn.expects(:[]).with('admin').returns(admin_db) + @conn.connect + end + + should "raise an error on invalid uris" do + ENV['MONGODB_URI'] = "mongo://localhost" + assert_raise MongoArgumentError do + Connection.new + end + + ENV['MONGODB_URI'] = "mongodb://localhost:abc" + assert_raise MongoArgumentError do + Connection.new + end + end + + should "require all of username, if password and db are specified" do + ENV['MONGODB_URI'] = "mongodb://kyle:jones@localhost/db?connect=false" + assert Connection.new + + ENV['MONGODB_URI'] = "mongodb://kyle:password@localhost" + assert_raise MongoArgumentError do + Connection.new + end + end + end end end diff --git a/test/uri_test.rb b/test/uri_test.rb index e833cfb..806c251 100644 --- a/test/uri_test.rb +++ b/test/uri_test.rb @@ -63,9 +63,10 @@ class URITest < Test::Unit::TestCase assert_equal "test", parser.auths[1]["db_name"] end - def test_opts_basic + def test_opts_with_semincolon_separator parser = Mongo::URIParser.new('mongodb://localhost:27018?connect=direct;slaveok=true;safe=true') assert_equal 'direct', parser.connect + assert parser.direct? assert parser.slaveok assert parser.safe end @@ -73,10 +74,17 @@ class URITest < Test::Unit::TestCase def test_opts_with_amp_separator parser = Mongo::URIParser.new('mongodb://localhost:27018?connect=direct&slaveok=true&safe=true') assert_equal 'direct', parser.connect + assert parser.direct? assert parser.slaveok assert parser.safe end + def test_opts_made_invalid_by_mixed_separators + assert_raise_error ArgumentError, "invalid data of application/x-www-form-urlencoded (replicaset=foo;bar&slaveok=true&safe=true)" do + Mongo::URIParser.new('mongodb://localhost:27018?replicaset=foo;bar&slaveok=true&safe=true') + end + end + def test_opts_safe parser = Mongo::URIParser.new('mongodb://localhost:27018?safe=true;w=2;journal=true;fsync=true;wtimeoutMS=200') assert parser.safe @@ -93,12 +101,16 @@ class URITest < Test::Unit::TestCase end def test_opts_replica_set - assert_raise_error MongoArgumentError, "specify that connect=replicaset" do - Mongo::URIParser.new('mongodb://localhost:27018?replicaset=foo') - end parser = Mongo::URIParser.new('mongodb://localhost:27018?connect=replicaset;replicaset=foo') assert_equal 'foo', parser.replicaset assert_equal 'replicaset', parser.connect + assert parser.replicaset? + end + + def test_opts_conflicting_replica_set + assert_raise_error MongoArgumentError, "connect=direct conflicts with setting a replicaset name" do + Mongo::URIParser.new('mongodb://localhost:27018?connect=direct;replicaset=foo') + end end def test_case_insensitivity