# frozen_string_literal: true # rubocop:todo all # Copyright (C) 2020 MongoDB Inc. # # Licensed under the Apache License, Version 2.0 (the 'License'); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an 'AS IS' BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. module Mongo class URI # Performs mapping between URI options and Ruby options. # # This class contains: # # - The mapping defining how URI options are converted to Ruby options. # - The mapping from downcased URI option names to canonical-cased URI # option names. # - Methods to perform conversion of URI option values to Ruby option # values (the convert_* methods). These generally warn and return nil # when input given is invalid. # - Methods to perform conversion of Ruby option values to standardized # MongoClient options (revert_* methods). These assume the input is valid # and generally do not perform validation. # # URI option names are case insensitive. Ruby options are specified as # symbols (though in Client options use indifferent access). # # @api private class OptionsMapper include Loggable # Instantates the options mapper. # # @option opts [ Logger ] :logger A custom logger to use. def initialize(**opts) @options = opts end # @return [ Hash ] The options. attr_reader :options # Adds an option to the uri options hash. # # Acquires a target for the option based on group. # Transforms the value. # Merges the option into the target. # # @param [ String ] key URI option name. # @param [ String ] value The value of the option. # @param [ Hash ] uri_options The base option target. def add_uri_option(key, value, uri_options) strategy = URI_OPTION_MAP[key.downcase] if strategy.nil? log_warn("Unsupported URI option '#{key}' on URI '#{@string}'. It will be ignored.") return end group = strategy[:group] target = if group uri_options[group] || {} else uri_options end value = apply_transform(key, value, strategy[:type]) # Sometimes the value here would be nil, for example if we are processing # read preference tags or auth mechanism properties and all of the # data within is invalid. Ignore such options. unless value.nil? merge_uri_option(target, value, strategy[:name]) end if group && !target.empty? && !uri_options.key?(group) uri_options[group] = target end end def smc_to_ruby(opts) uri_options = {} opts.each do |key, value| strategy = URI_OPTION_MAP[key.downcase] if strategy.nil? log_warn("Unsupported URI option '#{key}' on URI '#{@string}'. It will be ignored.") return end group = strategy[:group] target = if group uri_options[group] || {} else uri_options end value = apply_transform(key, value, strategy[:type]) # Sometimes the value here would be nil, for example if we are processing # read preference tags or auth mechanism properties and all of the # data within is invalid. Ignore such options. unless value.nil? merge_uri_option(target, value, strategy[:name]) end if group && !target.empty? && !uri_options.key?(group) uri_options[group] = target end end uri_options end # Converts Ruby options provided to "standardized MongoClient options". # # @param [ Hash ] opts Ruby options to convert. # # @return [ Hash ] Standardized MongoClient options. def ruby_to_smc(opts) rv = {} URI_OPTION_MAP.each do |uri_key, spec| if spec[:group] v = opts[spec[:group]] v = v && v[spec[:name]] else v = opts[spec[:name]] end unless v.nil? if type = spec[:type] v = send("revert_#{type}", v) end canonical_key = URI_OPTION_CANONICAL_NAMES[uri_key] unless canonical_key raise ArgumentError, "Option #{uri_key} is not known" end rv[canonical_key] = v end end # For options that default to true, remove the value if it is true. %w(retryReads retryWrites).each do |k| if rv[k] rv.delete(k) end end # Remove auth source when it is $external for mechanisms that default # (or require) that auth source. if %w(MONGODB-AWS).include?(rv['authMechanism']) && rv['authSource'] == '$external' rv.delete('authSource') end # ssl and tls are aliases, remove ssl ones rv.delete('ssl') # TODO remove authSource if it is the same as the database, # requires this method to know the database specified in the client. rv end # Converts Ruby options provided to their representation in a URI string. # # @param [ Hash ] opts Ruby options to convert. # # @return [ Hash ] URI string hash. def ruby_to_string(opts) rv = {} URI_OPTION_MAP.each do |uri_key, spec| if spec[:group] v = opts[spec[:group]] v = v && v[spec[:name]] else v = opts[spec[:name]] end unless v.nil? if type = spec[:type] v = send("stringify_#{type}", v) end canonical_key = URI_OPTION_CANONICAL_NAMES[uri_key] unless canonical_key raise ArgumentError, "Option #{uri_key} is not known" end rv[canonical_key] = v end end # For options that default to true, remove the value if it is true. %w(retryReads retryWrites).each do |k| if rv[k] rv.delete(k) end end # Remove auth source when it is $external for mechanisms that default # (or require) that auth source. if %w(MONGODB-AWS).include?(rv['authMechanism']) && rv['authSource'] == '$external' rv.delete('authSource') end # ssl and tls are aliases, remove ssl ones rv.delete('ssl') # TODO remove authSource if it is the same as the database, # requires this method to know the database specified in the client. rv end private # Applies URI value transformation by either using the default cast # or a transformation appropriate for the given type. # # @param [ String ] key URI option name. # @param [ String ] value The value to be transformed. # @param [ Symbol ] type The transform method. def apply_transform(key, value, type) if type send("convert_#{type}", key, value) else value end end # Merges a new option into the target. # # If the option exists at the target destination the merge will # be an addition. # # Specifically required to append an additional tag set # to the array of tag sets without overwriting the original. # # @param [ Hash ] target The destination. # @param [ Object ] value The value to be merged. # @param [ Symbol ] name The name of the option. def merge_uri_option(target, value, name) if target.key?(name) if REPEATABLE_OPTIONS.include?(name) target[name] += value else log_warn("Repeated option key: #{name}.") end else target.merge!(name => value) end end # Hash for storing map of URI option parameters to conversion strategies URI_OPTION_MAP = {} # @return [ Hash ] Map from lowercased to canonical URI # option names. URI_OPTION_CANONICAL_NAMES = {} # Simple internal dsl to register a MongoDB URI option in the URI_OPTION_MAP. # # @param [ String ] uri_key The MongoDB URI option to register. # @param [ Symbol ] name The name of the option in the driver. # @param [ Hash ] extra Extra options. # * :group [ Symbol ] Nested hash where option will go. # * :type [ Symbol ] Name of function to transform value. def self.uri_option(uri_key, name, **extra) URI_OPTION_MAP[uri_key.downcase] = { name: name }.update(extra) URI_OPTION_CANONICAL_NAMES[uri_key.downcase] = uri_key end # Replica Set Options uri_option 'replicaSet', :replica_set # Timeout Options uri_option 'connectTimeoutMS', :connect_timeout, type: :ms uri_option 'socketTimeoutMS', :socket_timeout, type: :ms uri_option 'serverSelectionTimeoutMS', :server_selection_timeout, type: :ms uri_option 'localThresholdMS', :local_threshold, type: :ms uri_option 'heartbeatFrequencyMS', :heartbeat_frequency, type: :ms uri_option 'maxIdleTimeMS', :max_idle_time, type: :ms # Write Options uri_option 'w', :w, group: :write_concern, type: :w uri_option 'journal', :j, group: :write_concern, type: :bool uri_option 'fsync', :fsync, group: :write_concern, type: :bool uri_option 'wTimeoutMS', :wtimeout, group: :write_concern, type: :integer # Read Options uri_option 'readPreference', :mode, group: :read, type: :read_mode uri_option 'readPreferenceTags', :tag_sets, group: :read, type: :read_tags uri_option 'maxStalenessSeconds', :max_staleness, group: :read, type: :max_staleness # Pool options uri_option 'maxConnecting', :max_connecting, type: :integer uri_option 'minPoolSize', :min_pool_size, type: :integer uri_option 'maxPoolSize', :max_pool_size, type: :integer uri_option 'waitQueueTimeoutMS', :wait_queue_timeout, type: :ms # Security Options uri_option 'ssl', :ssl, type: :repeated_bool uri_option 'tls', :ssl, type: :repeated_bool uri_option 'tlsAllowInvalidCertificates', :ssl_verify_certificate, type: :inverse_bool uri_option 'tlsAllowInvalidHostnames', :ssl_verify_hostname, type: :inverse_bool uri_option 'tlsCAFile', :ssl_ca_cert uri_option 'tlsCertificateKeyFile', :ssl_cert uri_option 'tlsCertificateKeyFilePassword', :ssl_key_pass_phrase uri_option 'tlsInsecure', :ssl_verify, type: :inverse_bool uri_option 'tlsDisableOCSPEndpointCheck', :ssl_verify_ocsp_endpoint, type: :inverse_bool # Topology options uri_option 'directConnection', :direct_connection, type: :bool uri_option 'connect', :connect, type: :symbol uri_option 'loadBalanced', :load_balanced, type: :bool uri_option 'srvMaxHosts', :srv_max_hosts, type: :integer uri_option 'srvServiceName', :srv_service_name # Auth Options uri_option 'authSource', :auth_source uri_option 'authMechanism', :auth_mech, type: :auth_mech uri_option 'authMechanismProperties', :auth_mech_properties, type: :auth_mech_props # Client Options uri_option 'appName', :app_name uri_option 'compressors', :compressors, type: :array uri_option 'readConcernLevel', :level, group: :read_concern, type: :symbol uri_option 'retryReads', :retry_reads, type: :bool uri_option 'retryWrites', :retry_writes, type: :bool uri_option 'zlibCompressionLevel', :zlib_compression_level, type: :zlib_compression_level # Converts +value+ to a boolean. # # Returns true for 'true', false for 'false', otherwise nil. # # @param [ String ] name Name of the URI option being processed. # @param [ String | true | false ] value URI option value. # # @return [ true | false | nil ] Converted value. def convert_bool(name, value) case value when true, "true", 'TRUE' true when false, "false", 'FALSE' false else log_warn("invalid boolean option for #{name}: #{value}") nil end end # Reverts a boolean type. # # @param [ true | false | nil ] value The boolean to revert. # # @return [ true | false | nil ] The passed value. def revert_bool(value) value end # Stringifies a boolean type. # # @param [ true | false | nil ] value The boolean. # # @return [ String | nil ] The string. def stringify_bool(value) revert_bool(value)&.to_s end # Converts the value into a boolean and returns it wrapped in an array. # # @param [ String ] name Name of the URI option being processed. # @param [ String ] value URI option value. # # @return [ Array | nil ] The boolean value parsed and wraped # in an array. def convert_repeated_bool(name, value) [convert_bool(name, value)] end # Reverts a repeated boolean type. # # @param [ Array | true | false | nil ] value The repeated boolean to revert. # # @return [ Array | true | false | nil ] The passed value. def revert_repeated_bool(value) value end # Stringifies a repeated boolean type. # # @param [ Array | nil ] value The repeated boolean. # # @return [ Array | nil ] The string. def stringify_repeated_bool(value) rep = revert_repeated_bool(value) if rep&.is_a?(Array) rep.join(",") else rep end end # Parses a boolean value and returns its inverse. # # @param [ String ] name Name of the URI option being processed. # @param [ String | true | false ] value The URI option value. # # @return [ true | false | nil ] The inverse of the boolean value parsed out, otherwise nil # (and a warning will be logged). def convert_inverse_bool(name, value) b = convert_bool(name, value) if b.nil? nil else !b end end # Reverts and inverts a boolean type. # # @param [ true | false | nil ] value The boolean to revert and invert. # # @return [ true | false | nil ] The inverted boolean. def revert_inverse_bool(value) value.nil? ? nil : !value end # Inverts and stringifies a boolean. # # @param [ true | false | nil ] value The boolean. # # @return [ String | nil ] The string. def stringify_inverse_bool(value) revert_inverse_bool(value)&.to_s end # Converts +value+ into an integer. Only converts positive integers. # # If the value is not a valid integer, warns and returns nil. # # @param [ String ] name Name of the URI option being processed. # @param [ String | Integer ] value URI option value. # # @return [ nil | Integer ] Converted value. def convert_integer(name, value) if value.is_a?(String) && /\A\d+\z/ !~ value log_warn("#{value} is not a valid integer for #{name}") return nil end value.to_i end # Reverts an integer. # # @param [ Integer | nil ] value The integer. # # @return [ Integer | nil ] The passed value. def revert_integer(value) value end # Stringifies an integer. # # @param [ Integer | nil ] value The integer. # # @return [ String | nil ] The string. def stringify_integer(value) revert_integer(value)&.to_s end # Ruby's convention is to provide timeouts in seconds, not milliseconds and # to use fractions where more precision is necessary. The connection string # options are always in MS so we provide an easy conversion type. # # @param [ String ] name Name of the URI option being processed. # @param [ String | Integer | Float ] value The millisecond value. # # @return [ Float ] The seconds value. # # @since 2.0.0 def convert_ms(name, value) case value when String if /\A-?\d+(\.\d+)?\z/ !~ value log_warn("Invalid ms value for #{name}: #{value}") return nil end if value.to_s[0] == '-' log_warn("#{name} cannot be a negative number") return nil end when Integer, Float if value < 0 log_warn("#{name} cannot be a negative number") return nil end else raise ArgumentError, "Can only convert Strings, Integers, or Floats to ms. Given: #{value.class}" end value.to_f / 1000 end # Reverts an ms. # # @param [ Float ] value The float. # # @return [ Integer ] The number multiplied by 1000 as an integer. def revert_ms(value) (value * 1000).round end # Stringifies an ms. # # @param [ Float ] value The float. # # @return [ String ] The string. def stringify_ms(value) revert_ms(value).to_s end # Converts +value+ into a symbol. # # @param [ String ] name Name of the URI option being processed. # @param [ String | Symbol ] value URI option value. # # @return [ Symbol ] Converted value. def convert_symbol(name, value) value.to_sym end # Reverts a symbol. # # @param [ Symbol ] value The symbol. # # @return [ String ] The passed value as a string. def revert_symbol(value) value.to_s end alias :stringify_symbol :revert_symbol # Extract values from the string and put them into an array. # # @param [ String ] name Name of the URI option being processed. # @param [ String ] value The string to build an array from. # # @return [ Array ] The array built from the string. def convert_array(name, value) value.split(',') end # Reverts an array. # # @param [ Array ] value An array of strings. # # @return [ Array ] The passed value. def revert_array(value) value end # Stringifies an array. # # @param [ Array ] value An array of strings. # # @return [ String ] The array joined by commas. def stringify_array(value) value.join(',') end # Authentication mechanism transformation. # # @param [ String ] name Name of the URI option being processed. # @param [ String ] value The authentication mechanism. # # @return [ Symbol ] The transformed authentication mechanism. def convert_auth_mech(name, value) auth_mech = AUTH_MECH_MAP[value.upcase] (auth_mech || value).tap do |mech| log_warn("#{value} is not a valid auth mechanism") unless auth_mech end end # Reverts auth mechanism. # # @param [ Symbol ] value The auth mechanism. # # @return [ String ] The auth mechanism as a string. # # @raise [ ArgumentError ] if its an invalid auth mechanism. def revert_auth_mech(value) found = AUTH_MECH_MAP.detect do |k, v| v == value end if found found.first else raise ArgumentError, "Unknown auth mechanism #{value}" end end # Stringifies auth mechanism. # # @param [ Symbol ] value The auth mechanism. # # @return [ String | nil ] The auth mechanism as a string. def stringify_auth_mech(value) revert_auth_mech(value) rescue nil end # Auth mechanism properties extractor. # # @param [ String ] name Name of the URI option being processed. # @param [ String ] value The auth mechanism properties string. # # @return [ Hash | nil ] The auth mechanism properties hash. def convert_auth_mech_props(name, value) properties = hash_extractor('authMechanismProperties', value) if properties properties.each do |k, v| if k.to_s.downcase == 'canonicalize_host_name' && v properties[k] = (v.downcase == 'true') end end end properties end # Reverts auth mechanism properties. # # @param [ Hash | nil ] value The auth mech properties. # # @return [ Hash | nil ] The passed value. def revert_auth_mech_props(value) value end # Stringifies auth mechanism properties. # # @param [ Hash | nil ] value The auth mech properties. # # @return [ String | nil ] The string. def stringify_auth_mech_props(value) return if value.nil? value.map { |k, v| "#{k}:#{v}" }.join(',') end # Parses the max staleness value, which must be either "0" or an integer # greater or equal to 90. # # @param [ String ] name Name of the URI option being processed. # @param [ String | Integer ] value The max staleness string. # # @return [ Integer | nil ] The max staleness integer parsed out if it is valid, otherwise nil # (and a warning will be logged). def convert_max_staleness(name, value) int = if value.is_a?(String) && /\A-?\d+\z/ =~ value value.to_i elsif value.is_a?(Integer) value end if int.nil? log_warn("Invalid max staleness value: #{value}") return nil end if int == -1 int = nil end if int && (int > 0 && int < 90 || int < 0) log_warn("max staleness should be either 0 or greater than 90: #{value}") int = nil end int end # Reverts max staleness. # # @param [ Integer | nil ] value The max staleness. # # @return [ Integer | nil ] The passed value. def revert_max_staleness(value) value end # Stringifies max staleness. # # @param [ Integer | nil ] value The max staleness. # # @return [ String | nil ] The string. def stringify_max_staleness(value) revert_max_staleness(value)&.to_s end # Read preference mode transformation. # # @param [ String ] name Name of the URI option being processed. # @param [ String ] value The read mode string value. # # @return [ Symbol | String ] The read mode. def convert_read_mode(name, value) READ_MODE_MAP[value.downcase] || value end # Reverts read mode. # # @param [ Symbol | String ] value The read mode. # # @return [ String ] The read mode as a string. def revert_read_mode(value) value.to_s.gsub(/_(\w)/) { $1.upcase } end alias :stringify_read_mode :revert_read_mode # Read preference tags transformation. # # @param [ String ] name Name of the URI option being processed. # @param [ String ] value The string representing tag set. # # @return [ Array | nil ] Array with tag set. def convert_read_tags(name, value) converted = convert_read_set(name, value) if converted [converted] else nil end end # Reverts read tags. # # @param [ Array | nil ] value The read tags. # # @return [ Array | nil ] The passed value. def revert_read_tags(value) value end # Stringifies read tags. # # @param [ Array | nil ] value The read tags. # # @return [ String | nil ] The joined string of read tags. def stringify_read_tags(value) value&.map { |ar| ar.map { |k, v| "#{k}:#{v}" }.join(',') } end # Read preference tag set extractor. # # @param [ String ] name Name of the URI option being processed. # @param [ String ] value The tag set string. # # @return [ Hash ] The tag set hash. def convert_read_set(name, value) hash_extractor('readPreferenceTags', value) end # Converts +value+ as a write concern. # # If +value+ is the word "majority", returns the symbol :majority. # If +value+ is a number, returns the number as an integer. # Otherwise returns the string +value+ unchanged. # # @param [ String ] name Name of the URI option being processed. # @param [ String | Integer ] value URI option value. # # @return [ Integer | Symbol | String ] Converted value. def convert_w(name, value) case value when 'majority' :majority when /\A[0-9]+\z/ value.to_i else value end end # Reverts write concern. # # @param [ Integer | Symbol | String ] value The write concern. # # @return [ Integer | String ] The write concern as a string. def revert_w(value) case value when Symbol value.to_s else value end end # Stringifies write concern. # # @param [ Integer | Symbol | String ] value The write concern. # # @return [ String ] The write concern as a string. def stringify_w(value) revert_w(value)&.to_s end # Parses the zlib compression level. # # @param [ String ] name Name of the URI option being processed. # @param [ String | Integer ] value The zlib compression level string. # # @return [ Integer | nil ] The compression level value if it is between -1 and 9 (inclusive), # otherwise nil (and a warning will be logged). def convert_zlib_compression_level(name, value) i = if value.is_a?(String) && /\A-?\d+\z/ =~ value value.to_i elsif value.is_a?(Integer) value end if i && (i >= -1 && i <= 9) i else log_warn("#{value} is not a valid zlibCompressionLevel") nil end end # Reverts zlib compression level # # @param [ Integer | nil ] value The write concern. # # @return [ Integer | nil ] The passed value. def revert_zlib_compression_level(value) value end # Stringifies zlib compression level # # @param [ Integer | nil ] value The write concern. # # @return [ String | nil ] The string. def stringify_zlib_compression_level(value) revert_zlib_compression_level(value)&.to_s end # Extract values from the string and put them into a nested hash. # # @param [ String ] name Name of the URI option being processed. # @param [ String ] value The string to build a hash from. # # @return [ Hash ] The hash built from the string. def hash_extractor(name, value) h = {} value.split(',').each do |tag| k, v = tag.split(':') if v.nil? log_warn("Invalid hash value for #{name}: key `#{k}` does not have a value: #{value}") next end h[k.to_sym] = v end if h.empty? nil else h end end end end end