lib/mongo/uri.rb in mongo-2.1.0.beta vs lib/mongo/uri.rb in mongo-2.1.0.rc0

- old
+ new

@@ -27,63 +27,121 @@ # # @since 2.0.0 class URI include Loggable - # Scheme Regex: non-capturing, matches scheme. + # The uri parser object options. # # @since 2.0.0 - SCHEME = %r{(?:mongodb://)}.freeze + attr_reader :options - # User Regex: capturing, group 1, matches anything but ':' + # The options specified in the uri. # - # @since 2.0.0 - USER = /([^:]+)/.freeze + # @since 2.1.0 + attr_reader :uri_options - # Password Regex: capturing, group 2, matches anything but '@' + # The servers specified in the uri. # # @since 2.0.0 - PASSWORD = /([^@]+)/.freeze + attr_reader :servers - # Credentials Regex: non capturing, matches 'user:password@' + # Unsafe characters that must be urlencoded. # - # @since 2.0.0 - CREDENTIALS = /(?:#{USER}:#{PASSWORD}?@)?/.freeze + # @since 2.1.0 + UNSAFE = /[\:\/\+\@]/ - # Host and port server Regex: matches anything but a forward slash + # Unix socket suffix. # - # @since 2.0.0 - HOSTPORT = /[^\/]+/.freeze + # @since 2.1.0 + UNIX_SOCKET = /.sock/ - # Unix socket server Regex: matches unix socket server + # The mongodb connection string scheme. # # @since 2.0.0 - UNIX = /\/.+.sock?/.freeze + SCHEME = 'mongodb://'.freeze - # server Regex: capturing, matches host and port server or unix server + # The character delimiting hosts. # - # @since 2.0.0 - SERVERS = /((?:(?:#{HOSTPORT}|#{UNIX}),?)+)/.freeze + # @since 2.1.0 + HOST_DELIM = ','.freeze - # Database Regex: matches anything but the characters that cannot - # be part of any MongoDB database name. + # The character separating a host and port. # - # @since 2.0.0 - DATABASE = %r{(?:/([^/\.\ "*<>:\|\?]*))?}.freeze + # @since 2.1.0 + HOST_PORT_DELIM = ':'.freeze - # Option Regex: only matches the ampersand separator and does - # not allow for semicolon to be used to separate options. + # The character delimiting a database. # - # @since 2.0.0 - OPTIONS = /(?:\?(?:(.+=.+)&?)+)*/.freeze + # @since 2.1.0 + DATABASE_DELIM = '/'.freeze - # Complete URI Regex: matches all of the combined components + # The character delimiting options. # - # @since 2.0.0 - URI = /#{SCHEME}#{CREDENTIALS}#{SERVERS}#{DATABASE}#{OPTIONS}/.freeze + # @since 2.1.0 + URI_OPTS_DELIM = '?'.freeze + # The character delimiting multiple options. + # + # @since 2.1.0 + INDIV_URI_OPTS_DELIM = '&'.freeze + # The character delimiting an option and its value. + # + # @since 2.1.0 + URI_OPTS_VALUE_DELIM = '='.freeze + + # The character separating a username from the password. + # + # @since 2.1.0 + AUTH_USER_PWD_DELIM = ':'.freeze + + # The character delimiting auth credentials. + # + # @since 2.1.0 + AUTH_DELIM = '@'.freeze + + # Error details for an invalid scheme. + # + # @since 2.1.0 + INVALID_SCHEME = "Invalid scheme. Scheme must be '#{SCHEME}'".freeze + + # Error details for an invalid options format. + # + # @since 2.1.0 + INVALID_OPTS_VALUE_DELIM = "Options and their values must be delimited" + + " by '#{URI_OPTS_VALUE_DELIM}'".freeze + + # Error details for an non-urlencoded user name or password. + # + # @since 2.1.0 + UNESCAPED_USER_PWD = "User name and password must be urlencoded.".freeze + + # Error details for a non-urlencoded unix socket path. + # + # @since 2.1.0 + UNESCAPED_UNIX_SOCKET = "UNIX domain sockets must be urlencoded.".freeze + + # Error details for a non-urlencoded auth databsae name. + # + # @since 2.1.0 + UNESCAPED_DATABASE = "Auth database must be urlencoded.".freeze + + # Error details for providing options without a database delimiter. + # + # @since 2.1.0 + INVALID_OPTS_DELIM = "Database delimiter '#{DATABASE_DELIM}' must be present if options are specified.".freeze + + # Error details for a missing host. + # + # @since 2.1.0 + INVALID_HOST = "Missing host; at least one must be provided.".freeze + + # Error details for an invalid port. + # + # @since 2.1.0 + INVALID_PORT = "Invalid port. Port must be an integer greater than 0 and less than 65536".freeze + # MongoDB URI format specification. # # @since 2.0.0 FORMAT = 'mongodb://[username:password@]host1[:port1][,host2[:port2]' + ',...[,hostN[:portN]]][/[database][?options]]'.freeze @@ -111,38 +169,34 @@ 'PLAIN' => :plain, 'MONGODB-CR' => :mongodb_cr, 'GSSAPI' => :gssapi }.freeze + # Options that are allowed to appear more than once in the uri. + # + # @since 2.1.0 + REPEATABLE_OPTIONS = [ :tag_sets ] + # Create the new uri from the provided string. # # @example Create the new URI. # URI.new('mongodb://localhost:27017') # # @param [ String ] string The uri string. + # @param [ Hash ] options The options. # - # @raise [ BadURI ] If the uri does not match the spec. + # @raise [ Error::InvalidURI ] If the uri does not match the spec. # # @since 2.0.0 - def initialize(string) + def initialize(string, options = {}) @string = string - @match = @string.match(URI) - raise Error::InvalidURI.new(string) unless @match + @options = options + empty, scheme, remaining = @string.partition(SCHEME) + raise_invalid_error!(INVALID_SCHEME) unless scheme == SCHEME + setup!(remaining) end - # Get the servers provided in the URI. - # - # @example Get the servers. - # uri.servers - # - # @return [ Array<String> ] The servers. - # - # @since 2.0.0 - def servers - @match[3].split(',') - end - # Gets the options hash that needs to be passed to a Mongo::Client on # instantiation, so we don't have to merge the credentials and database in # at that point - we only have a single point here. # # @example Get the client options. @@ -150,12 +204,12 @@ # # @return [ Hash ] The options passed to the Mongo::Client # # @since 2.0.0 def client_options - opts = options.merge(:database => database) - user ? opts.merge(credentials) : opts + opts = uri_options.merge(:database => database) + @user ? opts.merge(credentials) : opts end # Get the credentials provided in the URI. # # @example Get the credentials. @@ -165,11 +219,11 @@ # * :user [ String ] The user. # * :password [ String ] The provided password. # # @since 2.0.0 def credentials - { :user => user, :password => password } + { :user => @user, :password => @password } end # Get the database provided in the URI. # # @example Get the database. @@ -177,120 +231,166 @@ # # @return [String] The database. # # @since 2.0.0 def database - @match[4].nil? ? Database::ADMIN : @match[4] + @database ? @database : Database::ADMIN end - # Get the options provided in the URI. - # - # @example Get The options. - # uri.options - # - # @return [Hash] The options. - # - # Generic Options - # * :replica_set [String] replica set name - # * :connect_timeout [Fixnum] connect timeout - # * :socket_timeout [Fixnum] socket timeout - # * :ssl [true, false] ssl enabled? - # - # Write Options (returned in a hash under the :write key) - # * :w [String, Fixnum] write concern value - # * :j [true, false] journal - # * :fsync [true, false] fsync - # * :timeout [Fixnum] timeout for write operation - # - # Read Options (returned in a hash under the :read key) - # * :mode [Symbol] read mode - # * :tag_sets [Array<Hash>] read tag sets - # - # @since 2.0.0 - def options - parsed_options = @match[5] - return {} unless parsed_options - parsed_options.split('&').reduce({}) do |options, option| - key, value = option.split('=') - strategy = OPTION_MAP[key] + private + + def setup!(remaining) + creds_hosts, db_opts = extract_db_opts!(remaining) + parse_creds_hosts!(creds_hosts) + parse_db_opts!(db_opts) + end + + def extract_db_opts!(string) + db_opts, d, creds_hosts = string.reverse.partition(DATABASE_DELIM) + db_opts, creds_hosts = creds_hosts, db_opts if creds_hosts.empty? + if db_opts.empty? && creds_hosts.include?(URI_OPTS_DELIM) + raise_invalid_error!(INVALID_OPTS_DELIM) + end + [ creds_hosts, db_opts ].map { |s| s.reverse } + end + + def parse_creds_hosts!(string) + hosts, creds = split_creds_hosts(string) + @servers = parse_servers!(hosts) + @user = parse_user!(creds) + @password = parse_password!(creds) + end + + def split_creds_hosts(string) + hosts, d, creds = string.reverse.partition(AUTH_DELIM) + hosts, creds = creds, hosts if hosts.empty? + [ hosts, creds ].map { |s| s.reverse } + end + + def parse_db_opts!(string) + auth_db, d, uri_opts = string.partition(URI_OPTS_DELIM) + @uri_options = parse_uri_options!(uri_opts) + @database = parse_database!(auth_db) + end + + def parse_uri_options!(string) + return {} unless string + string.split(INDIV_URI_OPTS_DELIM).reduce({}) do |uri_options, opt| + raise_invalid_error!(INVALID_OPTS_VALUE_DELIM) unless opt.index(URI_OPTS_VALUE_DELIM) + key, value = opt.split(URI_OPTS_VALUE_DELIM) + strategy = URI_OPTION_MAP[key.downcase] if strategy.nil? - log_warn([ - "Unsupported URI option '#{key}' on URI '#{@string}'. It will be ignored." - ]) + log_warn("Unsupported URI option '#{key}' on URI '#{@string}'. It will be ignored.") else - add_option(strategy, value, options) + add_uri_option(strategy, value, uri_options) end - options + uri_options end end - private + def parse_user!(string) + if (string && user = string.partition(AUTH_USER_PWD_DELIM)[0]) + raise_invalid_error!(UNESCAPED_USER_PWD) if user =~ UNSAFE + decode(user) if user.length > 0 + end + end + def parse_password!(string) + if (string && pwd = string.partition(AUTH_USER_PWD_DELIM)[2]) + raise_invalid_error!(UNESCAPED_USER_PWD) if pwd =~ UNSAFE + decode(pwd) if pwd.length > 0 + end + end + + def parse_database!(string) + raise_invalid_error!(UNESCAPED_DATABASE) if string =~ UNSAFE + decode(string) if string.length > 0 + end + + def validate_port_string!(port) + unless port.nil? || (port.length > 0 && port.to_i > 0 && port.to_i <= 65535) + raise_invalid_error!(INVALID_PORT) + end + end + + def parse_servers!(string) + raise_invalid_error!(INVALID_HOST) unless string.size > 0 + string.split(HOST_DELIM).reduce([]) do |servers, host| + if host[0] == '[' + if host.index(']:') + h, p = host.split(']:') + validate_port_string!(p) + end + elsif host.index(HOST_PORT_DELIM) + h, d, p = host.partition(HOST_PORT_DELIM) + raise_invalid_error!(INVALID_HOST) unless h.size > 0 + validate_port_string!(p) + elsif host =~ UNIX_SOCKET + raise_invalid_error!(UNESCAPED_UNIX_SOCKET) if host =~ UNSAFE + end + servers << host + end + end + + def raise_invalid_error!(details) + raise Error::InvalidURI.new(@string, details) + end + + def decode(value) + ::URI.decode(value) + end + # Hash for storing map of URI option parameters to conversion strategies - OPTION_MAP = {} + URI_OPTION_MAP = {} - # Simple internal dsl to register a MongoDB URI option in the OPTION_MAP. + # Simple internal dsl to register a MongoDB URI option in the URI_OPTION_MAP. # # @param uri_key [String] The MongoDB URI option to register. # @param name [Symbol] The name of the option in the driver. # @param extra [Hash] Extra options. # * :group [Symbol] Nested hash where option will go. # * :type [Symbol] Name of function to transform value. - def self.option(uri_key, name, extra = {}) - OPTION_MAP[uri_key] = { :name => name }.merge(extra) + def self.uri_option(uri_key, name, extra = {}) + URI_OPTION_MAP[uri_key] = { :name => name }.merge(extra) end # Replica Set Options - option 'replicaSet', :replica_set, :type => :replica_set + uri_option 'replicaset', :replica_set, :type => :replica_set # Timeout Options - option 'connectTimeoutMS', :connect_timeout, :type => :ms_convert - option 'socketTimeoutMS', :socket_timeout, :type => :ms_convert - option 'serverSelectionTimeoutMS', :server_selection_timeout, :type => :ms_convert - option 'localThresholdMS', :local_threshold, :type => :ms_convert + uri_option 'connecttimeoutms', :connect_timeout, :type => :ms_convert + uri_option 'sockettimeoutms', :socket_timeout, :type => :ms_convert + uri_option 'serverselectiontimeoutms', :server_selection_timeout, :type => :ms_convert + uri_option 'localthresholdms', :local_threshold, :type => :ms_convert # Write Options - option 'w', :w, :group => :write - option 'journal', :j, :group => :write - option 'fsync', :fsync, :group => :write - option 'wtimeoutMS', :timeout, :group => :write + uri_option 'w', :w, :group => :write + uri_option 'journal', :j, :group => :write + uri_option 'fsync', :fsync, :group => :write + uri_option 'wtimeoutms', :timeout, :group => :write # Read Options - option 'readPreference', :mode, :group => :read, :type => :read_mode - option 'readPreferenceTags', :tag_sets, :group => :read, :type => :read_tags + uri_option 'readpreference', :mode, :group => :read, :type => :read_mode + uri_option 'readpreferencetags', :tag_sets, :group => :read, :type => :read_tags # Pool options - option 'minPoolSize', :min_pool_size - option 'maxPoolSize', :max_pool_size - option 'waitQueueTimeoutMS', :wait_queue_timeout, :type => :ms_convert + uri_option 'minpoolsize', :min_pool_size + uri_option 'maxpoolsize', :max_pool_size + uri_option 'waitqueuetimeoutms', :wait_queue_timeout, :type => :ms_convert # Security Options - option 'ssl', :ssl + uri_option 'ssl', :ssl # Topology options - option 'connect', :connect + uri_option 'connect', :connect # Auth Options - option 'authSource', :source, :group => :auth, :type => :auth_source - option 'authMechanism', :mechanism, :group => :auth, :type => :auth_mech - option 'authMechanismProperties', :auth_mech_properties, :group => :auth, - :type => :auth_mech_props + uri_option 'authsource', :source, :group => :auth, :type => :auth_source + uri_option 'authmechanism', :auth_mech, :type => :auth_mech + uri_option 'authmechanismproperties', :auth_mech_properties, :group => :auth, + :type => :auth_mech_props - # Gets the user provided in the URI - # - # @return [String] The user. - def user - @match[1] - end - - # Gets the password provided in the URI - # - # @return [String] The password. - def password - @match[2] - end - # Casts option values that do not have a specifically provided # transofrmation to the appropriate type. # # @param value [String] The value to be cast. # @@ -301,11 +401,11 @@ elsif value == 'false' false elsif value =~ /[\d]/ value.to_i else - value.to_sym + decode(value).to_sym end end # Applies URI value transformation by either using the default cast # or a transformation appropriate for the given type. @@ -320,19 +420,19 @@ end end # Selects the output destination for an option. # - # @param options [Hash] The base target. - # @param group [Symbol] Group subtarget. + # @param [Hash] uri_options The base target. + # @param [Symbol] group Group subtarget. # # @return [Hash] The target for the option. - def select_target(options, group = nil) + def select_target(uri_options, group = nil) if group - options[group] ||= {} + uri_options[group] ||= {} else - options + uri_options end end # Merges a new option into the target. # @@ -343,50 +443,54 @@ # to the array of tag sets without overwriting the original. # # @param target [Hash] The destination. # @param value [Object] The value to be merged. # @param name [Symbol] The name of the option. - def merge_option(target, value, name) + def merge_uri_option(target, value, name) if target.key?(name) - target[name] += value + if REPEATABLE_OPTIONS.include?(name) + target[name] += value + else + log_warn("Repeated option key: #{name}.") + end else target.merge!(name => value) end end - # Adds an option to the options hash via the supplied strategy. + # Adds an option to the uri options hash via the supplied strategy. # # Acquires a target for the option based on group. # Transforms the value. # Merges the option into the target. # # @param strategy [Symbol] The strategy for this option. # @param value [String] The value of the option. - # @param options [Hash] The base option target. - def add_option(strategy, value, options) - target = select_target(options, strategy[:group]) + # @param uri_options [Hash] The base option target. + def add_uri_option(strategy, value, uri_options) + target = select_target(uri_options, strategy[:group]) value = apply_transform(value, strategy[:type]) - merge_option(target, value, strategy[:name]) + merge_uri_option(target, value, strategy[:name]) end # Replica set transformation, avoid converting to Symbol. # # @param value [String] Replica set name. # # @return [String] Same value to avoid cast to Symbol. def replica_set(value) - value + decode(value) end # Auth source transformation, either db string or :external. # # @param value [String] Authentication source. # # @return [String] If auth source is database name. # @return [:external] If auth source is external authentication. def auth_source(value) - value == '$external' ? :external : value + value == '$external' ? :external : decode(value) end # Authentication mechanism transformation. # # @param value [String] The authentication mechanism. @@ -456,10 +560,10 @@ # # @return [ Hash ] The hash built from the string. def hash_extractor(value) value.split(',').reduce({}) do |set, tag| k, v = tag.split(':') - set.merge(k.downcase.to_sym => v) + set.merge(decode(k).downcase.to_sym => decode(v)) end end end end